Skip to main content

wire/
character.rs

1//! Character — deterministic nickname, emoji, and color palette per identity.
2//!
3//! Each wire identity has a Character derived deterministically from its DID
4//! (or any other stable seed). Same DID → same Character forever. Used for:
5//!
6//! - Terminal statusline display (`wire whoami --colored`)
7//! - Visual disambiguation between multiple Claude sessions on the same host
8//! - Future agent-card publication (federation lifecycle)
9//!
10//! Character is *display layer* only. It does not affect protocol semantics,
11//! signing, or peer routing — those continue to use the DID. Character is the
12//! human-friendly handle the operator sees.
13//!
14//! Field naming follows the ecosystem convention (`persona` not `soul`, per
15//! Letta) surfaced in the identity-primitive survey that motivated this layer.
16
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20/// A character for an identity: human-readable nickname, emoji, and palette.
21///
22/// Constructed deterministically from a seed (typically the DID). The same
23/// seed always produces the same Character — operators can rely on
24/// "🦊 foxtrot-meadow" persisting across daemon restarts, machine migration,
25/// and process boundaries.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct Character {
28    /// Adjective-noun pair, lowercase, hyphen-joined. e.g. `"foxtrot-meadow"`.
29    pub nickname: String,
30    /// Single-codepoint (or VS-16-qualified) emoji glyph. e.g. `"🦊"`.
31    pub emoji: String,
32    /// Two-color palette for terminal/UI display.
33    pub palette: Palette,
34}
35
36/// Two-color palette derived from the same seed as the nickname/emoji.
37///
38/// Primary is bounded to be terminal-readable on both light and dark
39/// backgrounds (L ∈ [0.50, 0.65]). Accent shifts hue +30° and lifts L to
40/// [0.65, 0.80] for highlights. Saturation is bounded [0.55, 0.80] to avoid
41/// muddy / neon extremes.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct Palette {
44    /// Primary color as `#rrggbb`. Use for the nickname/emoji glyph itself.
45    pub primary_hex: String,
46    /// Accent color as `#rrggbb`. Use for highlights, borders, accents.
47    pub accent_hex: String,
48    /// Primary mapped onto the ANSI 256-color cube (16..=231).
49    pub ansi256_primary: u8,
50    /// Accent mapped onto the ANSI 256-color cube (16..=231).
51    pub ansi256_accent: u8,
52}
53
54impl Character {
55    /// Derive a Character from a wire DID (e.g. `did:wire:paul-a1b2c3d4`).
56    ///
57    /// v0.11 ONE-NAME: derive the character from the DID's pubkey
58    /// fingerprint suffix only (the trailing 8-hex after the final `-`).
59    /// This makes the character a deterministic function of the
60    /// PUBLIC KEY, NOT of the handle-in-DID. Critical for the v0.11
61    /// invariant: `wire init` sets agent-card.handle = character, which
62    /// rewrites the DID's handle portion; if the character changed
63    /// because of the rewrite, we'd be back to two-name confusion
64    /// (operator-typed handle yields one character, character-as-handle
65    /// yields another). Fingerprint-only seeding closes the loop —
66    /// whatever handle ends up in the DID, the character is the same.
67    ///
68    /// Back-compat: pre-v0.5.7 DIDs (no fingerprint suffix) and any
69    /// malformed DID fall back to hashing the full string, so legacy
70    /// peers still get a stable (if different) character.
71    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        // Legacy / malformed: seed from the full string for stability.
80        Self::from_seed(did.as_bytes())
81    }
82
83    /// Derive a Character from a pinned peer's agent-card JSON object.
84    ///
85    /// v0.7.0-alpha.6: when a peer has published their operator-chosen
86    /// character (display.nickname / display.emoji on their signed
87    /// agent-card), we honor it. Otherwise falls back to auto-derived
88    /// from their DID — same as `from_did`.
89    ///
90    /// v0.7.0-alpha.8 (review-fix #1): peer-published override fields
91    /// are sanitized (control chars stripped, length-capped) before use
92    /// so a malicious peer cannot inject ANSI/OSC escape sequences via
93    /// their display.nickname / display.emoji and execute terminal
94    /// control codes on every `wire peers` / `wire whoami` render.
95    /// Override that fully sanitizes to empty falls back to auto-derived.
96    ///
97    /// v0.7.0-alpha.8 (review-fix #8): missing or non-string `did`
98    /// returns a distinctive "unknown peer" sentinel character rather
99    /// than collapsing all such peers onto the empty-string-derived
100    /// character. Surfaces partially-corrupt pinned cards to operators
101    /// rather than masking them as one fake identity.
102    ///
103    /// Backward compat: agent-cards without the `display` field land in
104    /// the auto-derived path automatically.
105    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    /// Sentinel for peers whose pinned agent-card lacks a usable DID.
126    /// Distinct, visible, non-overlapping with the auto-derived space
127    /// (no real DID will hash to the empty string, and the explicit "?"
128    /// emoji isn't in the curated EMOJIS list).
129    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    /// Derive a Character from a DID, optionally overriding the nickname
143    /// and/or emoji with operator-chosen values.
144    ///
145    /// v0.7.0-alpha.3: agents can name themselves. The palette stays
146    /// deterministic (derived from DID hash) so the visual color identity
147    /// remains stable even when the operator picks a custom name; only
148    /// the textual + emoji fields override. Empty-string override is
149    /// treated as "unset" (falls back to auto-derived).
150    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    /// Derive a Character from an arbitrary byte seed.
170    ///
171    /// Exposed for testing and for callers that already have a high-entropy
172    /// seed (e.g. an Ed25519 public key). Production code generally calls
173    /// `from_did` instead.
174    pub fn from_seed(seed: &[u8]) -> Self {
175        let mut h = Sha256::new();
176        h.update(seed);
177        let digest = h.finalize();
178        // 32 bytes of entropy. Use distinct slices for each derived field so
179        // adjustments to one decision do not perturb the others.
180        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        // Hue in [0, 360). Saturation + lightness drawn from bounded ranges.
186        let hue_raw = u32::from_be_bytes(digest[12..16].try_into().unwrap());
187        let hue_deg = (hue_raw % 3600) as f32 / 10.0; // 0.0..360.0
188        let sat = 0.55 + (digest[16] as f32 / 255.0) * 0.25; // 0.55..0.80
189        let light = 0.50 + (digest[17] as f32 / 255.0) * 0.15; // 0.50..0.65
190        let accent_hue_deg = (hue_deg + 30.0) % 360.0;
191        let accent_light = 0.65 + (digest[18] as f32 / 255.0) * 0.15; // 0.65..0.80
192
193        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    /// `"🦊 foxtrot-meadow"` — plain, no ANSI escapes. Safe in any output.
209    pub fn short(&self) -> String {
210        format!("{} {}", self.emoji, self.nickname)
211    }
212
213    /// `short()` wrapped in ANSI 256-color foreground escapes for the primary
214    /// color. Renders correctly in any terminal supporting 256 colors (the
215    /// universal lower bound — every modern emulator). For terminals without
216    /// color support, escapes will be visible-but-harmless.
217    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
225/// v0.7.0-alpha.8 (review-fix #1): sanitize operator-chosen or peer-
226/// published display text (nickname or emoji) for safe terminal render.
227///
228/// Strips Unicode Control category chars (`is_control()` — covers C0,
229/// DEL, C1 including ESC U+001B which gates ANSI/OSC/CSI escape
230/// sequences), then caps length to `MAX_DISPLAY_CHARS` codepoints so a
231/// malicious peer can't ship a 10MB nickname that destroys the
232/// statusline layout.
233///
234/// Used at write time (`wire identity rename` rejects sanitization-
235/// reduced inputs as an error) and at read time (`Character::from_card`
236/// silently strips for defense-in-depth against pinned cards that
237/// pre-date this validation).
238pub 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
247/// HSL → RGB. h ∈ [0, 360), s ∈ [0, 1], l ∈ [0, 1]. Returns u8 triplet.
248/// Standard formula; no clamping needed when s/l are already in-range.
249fn 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
268/// Nearest color in the ANSI 256 6×6×6 cube. Returns an index in 16..=231.
269fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
270    let q = |c: u8| -> u8 { ((c as u16 * 5 + 127) / 255) as u8 }; // round to 0..=5
271    16 + 36 * q(r) + 6 * q(g) + q(b)
272}
273
274/// v0.9.3: emoji-rendering capability probe.
275///
276/// Returns `true` when the operator's terminal is likely to render
277/// emoji (UTF-8 locale OR a known-modern TERM/TERM_PROGRAM). On a
278/// fresh Windows 10 `cmd.exe` with the default raster font this
279/// returns `false`, allowing `emoji_with_fallback` to substitute an
280/// ASCII tag (`[bear]`) so first-time UX isn't broken squares.
281///
282/// Override knobs (highest priority first):
283///   `WIRE_EMOJI=on`  — force emoji glyphs
284///   `WIRE_EMOJI=off` — force ASCII fallback
285///
286/// Defaults to allowing emoji unless we can prove the terminal can't.
287pub 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    // Modern terminals tagged via TERM_PROGRAM all render emoji.
292    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        // Anything reporting xterm-256color / *-256color is modern
298        // enough for emoji rendering on every OS we ship to.
299        if t.contains("256color") || t.contains("kitty") || t.contains("alacritty") {
300            return true;
301        }
302    }
303    // UTF-8 locale is a strong signal even on cmd.exe-class shells.
304    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    // On Windows we conservatively default to NO emoji unless the
312    // operator opted in via WIRE_EMOJI=on. Most cmd.exe sessions
313    // render `🐻` as a hollow box; better to print `[bear]` than
314    // mystery glyphs.
315    if cfg!(windows) {
316        return false;
317    }
318    // POSIX systems without TERM/LANG signal: lean optimistic.
319    true
320}
321
322/// v0.9.3: render `[<word>]` ASCII fallback when terminal_supports_emoji
323/// returns false. The word is the emoji's canonical short-name from a
324/// tiny built-in lookup; unknown emoji fall back to `[*]`.
325///
326/// Returns the emoji glyph unchanged when rendering is supported.
327pub fn emoji_with_fallback(ch: &Character) -> String {
328    if terminal_supports_emoji() {
329        return ch.emoji.clone();
330    }
331    // Map every character-system emoji to an ASCII short-name. The
332    // list mirrors EMOJIS in this file; any glyph not in the lookup
333    // becomes `[*]`.
334    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
387/// ~256 short, neutral adjectives. Nature, abstract, texture, mood.
388/// v0.7.0-alpha.4: doubled from the alpha.1 set of 120 to widen the
389/// combinatorial space and reduce nickname collisions at scale.
390const 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
636/// ~256 short, evocative nouns. Geographic features, weather, materials,
637/// flora, fauna, objects, light.
638/// v0.7.0-alpha.4: doubled from the alpha.1 set of 120.
639const 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
884/// ~144 curated emojis. All single Unicode codepoint —
885/// no flags, no skin tone, no ZWJ family/profession sequences. Render
886/// consistently across iTerm, Terminal.app, Alacritty, kitty, GNOME
887/// Terminal, Konsole, and tmux.
888/// v0.7.0-alpha.4: more than doubled from the alpha.1 set of 64. Themed
889/// across animals (fauna heavy because they're the most evocative),
890/// flora, weather/sky, food, music, and abstract symbols.
891const EMOJIS: &[&str] = &[
892    // Animals — mammals
893    "🦊", "🐺", "🐻", "🐅", "🐆", "🦓", "🦒", "🦌", "🦘", "🐇", "🦔", "🦣", "🦏", "🐈", "🐱", "🐶",
894    "🐰", "🦦", "🦥", "🦡", "🦨", "🦄", "🐴", "🐗", "🐘", "🦬", "🦫", "🐪", "🦙", "🐭", "🐹", "🐀",
895    // Animals — birds
896    "🦅", "🦉", "🦢", "🦩", "🐧", "🦃", "🦚", "🦜", "🦤", "🦆", "🐓", "🐔", "🐦", "🪶",
897    // Animals — reptiles + amphibians
898    "🐊", "🦎", "🐍", "🐢", "🦕", "🦖", "🐸", // Animals — sea
899    "🐙", "🐬", "🐳", "🐋", "🐡", "🦈", "🦭", "🐟", "🐠", "🦀", "🦞", "🦐", "🐚",
900    // Animals — bugs
901    "🐝", "🦋", "🐌", "🐞", "🦗", "🕷", "🦂", // Plants — trees
902    "🌲", "🌳", "🌴", "🌵", "🌱", "🌿", "🍃", "🍀", "🍁", "🍂", // Plants — flowers
903    "🌷", "🌸", "🌺", "🌻", "🌼", "🌹", "🪻", "🪷", "🍄", // Plants — fruits
904    "🍇", "🍈", "🍉", "🍊", "🍋", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", "🍓", "🫐", "🥝",
905    // Weather + sky
906    "🌊", "🌋", "🌙", "🌟", "🌈", "🔥", "❄", "💧", "⚡", "☀", "☁", "⛄",
907    // Light + abstract
908    "💎", "🪄", "🔮", "🧿", "🌠", // Music
909    "🎵", "🎶", "🎷", "🎸", "🎹", "🎺", "🎻", "🥁", "🪕", "🪈", // Objects + travel
910    "⚓", "🧭", "🏺", "🪴", "🗿", "🛡", "🗝", "🎲", "🎭", "🎨", "🎯", "🪐",
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        // 14400 possible nickname combinations; in 10k random DIDs we'll
985        // see *some* collisions by birthday paradox (~3500 expected). Check
986        // that *characters* (full triple) are unique enough — collisions in
987        // (nickname, emoji, primary_hex) below 1% across 10k samples.
988        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        // Red: H=0, S=1, L=0.5 → (255, 0, 0)
1028        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        // Green: H=120, S=1, L=0.5 → (0, 255, 0)
1033        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        // Blue: H=240, S=1, L=0.5 → (0, 0, 255)
1038        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        // The core attack vector: peer publishes display.nickname with
1047        // ESC ] 0 ; pwned BEL → terminal renames window. ESC + BEL are
1048        // U+001B / U+0007 (control chars); the `]` and `;` and visible
1049        // text are printable and survive but are harmless without ESC.
1050        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        // The visible-but-now-harmless residue.
1054        assert_eq!(out, "]0;owned");
1055        // CSI sequences also defanged (ESC gone).
1056        let out2 = sanitize_display_text("\x1b[2J\x1b[H");
1057        assert!(!out2.contains('\x1b'));
1058        assert_eq!(out2, "[2J[H");
1059        // Newlines / tabs / DEL also stripped.
1060        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        // The exact silent-collision scenario from review-fix #8: a
1083        // pinned peer card with null/missing/empty did used to collapse
1084        // every such peer onto from_did("") — same character for all.
1085        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        // Defense-in-depth: even if a malicious peer signed a card with
1101        // ANSI escapes in display.nickname before this validation
1102        // shipped, we strip them at read time so the operator's
1103        // terminal stays safe.
1104        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        // ESC + OSC delimiters removed; what's left is the visible text.
1110        assert!(!c.nickname.contains('\x1b'));
1111        assert!(!c.nickname.contains('\x07'));
1112        assert!(c.nickname.contains("OWNED")); // visible text preserved
1113        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        // Pure black corner of cube → 16. Pure white → 231.
1138        assert_eq!(rgb_to_ansi256(0, 0, 0), 16);
1139        assert_eq!(rgb_to_ansi256(255, 255, 255), 231);
1140        // Red corner (255, 0, 0) → 16 + 5*36 = 196.
1141        assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
1142        // Green corner (0, 255, 0) → 16 + 5*6 = 46.
1143        assert_eq!(rgb_to_ansi256(0, 255, 0), 46);
1144    }
1145}