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