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    /// SHA-256 of the DID drives both nickname/emoji selection and HSL hue.
59    /// The function is total and deterministic — every input string yields
60    /// some valid Character.
61    pub fn from_did(did: &str) -> Self {
62        Self::from_seed(did.as_bytes())
63    }
64
65    /// Derive a Character from a pinned peer's agent-card JSON object.
66    ///
67    /// v0.7.0-alpha.6: when a peer has published their operator-chosen
68    /// character (display.nickname / display.emoji on their signed
69    /// agent-card), we honor it. Otherwise falls back to auto-derived
70    /// from their DID — same as `from_did`.
71    ///
72    /// v0.7.0-alpha.8 (review-fix #1): peer-published override fields
73    /// are sanitized (control chars stripped, length-capped) before use
74    /// so a malicious peer cannot inject ANSI/OSC escape sequences via
75    /// their display.nickname / display.emoji and execute terminal
76    /// control codes on every `wire peers` / `wire whoami` render.
77    /// Override that fully sanitizes to empty falls back to auto-derived.
78    ///
79    /// v0.7.0-alpha.8 (review-fix #8): missing or non-string `did`
80    /// returns a distinctive "unknown peer" sentinel character rather
81    /// than collapsing all such peers onto the empty-string-derived
82    /// character. Surfaces partially-corrupt pinned cards to operators
83    /// rather than masking them as one fake identity.
84    ///
85    /// Backward compat: agent-cards without the `display` field land in
86    /// the auto-derived path automatically.
87    pub fn from_card(card: &serde_json::Value) -> Self {
88        let did_opt = card.get("did").and_then(|d| d.as_str());
89        let did = match did_opt {
90            Some(d) if !d.is_empty() => d,
91            _ => return Self::unknown_peer(),
92        };
93        let display = card.get("display").and_then(|d| d.as_object());
94        let nick = display
95            .and_then(|d| d.get("nickname"))
96            .and_then(|n| n.as_str())
97            .map(sanitize_display_text)
98            .filter(|s| !s.is_empty());
99        let emoji = display
100            .and_then(|d| d.get("emoji"))
101            .and_then(|e| e.as_str())
102            .map(sanitize_display_text)
103            .filter(|s| !s.is_empty());
104        Self::from_did_with_override(did, nick.as_deref(), emoji.as_deref())
105    }
106
107    /// Sentinel for peers whose pinned agent-card lacks a usable DID.
108    /// Distinct, visible, non-overlapping with the auto-derived space
109    /// (no real DID will hash to the empty string, and the explicit "?"
110    /// emoji isn't in the curated EMOJIS list).
111    fn unknown_peer() -> Self {
112        Self {
113            nickname: "unknown-peer".to_string(),
114            emoji: "❓".to_string(),
115            palette: Palette {
116                primary_hex: "#7d7d7d".to_string(),
117                accent_hex: "#a8a8a8".to_string(),
118                ansi256_primary: 244,
119                ansi256_accent: 248,
120            },
121        }
122    }
123
124    /// Derive a Character from a DID, optionally overriding the nickname
125    /// and/or emoji with operator-chosen values.
126    ///
127    /// v0.7.0-alpha.3: agents can name themselves. The palette stays
128    /// deterministic (derived from DID hash) so the visual color identity
129    /// remains stable even when the operator picks a custom name; only
130    /// the textual + emoji fields override. Empty-string override is
131    /// treated as "unset" (falls back to auto-derived).
132    pub fn from_did_with_override(
133        did: &str,
134        nickname_override: Option<&str>,
135        emoji_override: Option<&str>,
136    ) -> Self {
137        let auto = Self::from_did(did);
138        Self {
139            nickname: nickname_override
140                .filter(|s| !s.is_empty())
141                .map(str::to_string)
142                .unwrap_or(auto.nickname),
143            emoji: emoji_override
144                .filter(|s| !s.is_empty())
145                .map(str::to_string)
146                .unwrap_or(auto.emoji),
147            palette: auto.palette,
148        }
149    }
150
151    /// Derive a Character from an arbitrary byte seed.
152    ///
153    /// Exposed for testing and for callers that already have a high-entropy
154    /// seed (e.g. an Ed25519 public key). Production code generally calls
155    /// `from_did` instead.
156    pub fn from_seed(seed: &[u8]) -> Self {
157        let mut h = Sha256::new();
158        h.update(seed);
159        let digest = h.finalize();
160        // 32 bytes of entropy. Use distinct slices for each derived field so
161        // adjustments to one decision do not perturb the others.
162        let adj_idx =
163            u32::from_be_bytes(digest[0..4].try_into().unwrap()) as usize % ADJECTIVES.len();
164        let noun_idx = u32::from_be_bytes(digest[4..8].try_into().unwrap()) as usize % NOUNS.len();
165        let emoji_idx =
166            u32::from_be_bytes(digest[8..12].try_into().unwrap()) as usize % EMOJIS.len();
167        // Hue in [0, 360). Saturation + lightness drawn from bounded ranges.
168        let hue_raw = u32::from_be_bytes(digest[12..16].try_into().unwrap());
169        let hue_deg = (hue_raw % 3600) as f32 / 10.0; // 0.0..360.0
170        let sat = 0.55 + (digest[16] as f32 / 255.0) * 0.25; // 0.55..0.80
171        let light = 0.50 + (digest[17] as f32 / 255.0) * 0.15; // 0.50..0.65
172        let accent_hue_deg = (hue_deg + 30.0) % 360.0;
173        let accent_light = 0.65 + (digest[18] as f32 / 255.0) * 0.15; // 0.65..0.80
174
175        let (pr, pg, pb) = hsl_to_rgb(hue_deg, sat, light);
176        let (ar, ag, ab) = hsl_to_rgb(accent_hue_deg, sat, accent_light);
177
178        Self {
179            nickname: format!("{}-{}", ADJECTIVES[adj_idx], NOUNS[noun_idx]),
180            emoji: EMOJIS[emoji_idx].to_string(),
181            palette: Palette {
182                primary_hex: format!("#{:02x}{:02x}{:02x}", pr, pg, pb),
183                accent_hex: format!("#{:02x}{:02x}{:02x}", ar, ag, ab),
184                ansi256_primary: rgb_to_ansi256(pr, pg, pb),
185                ansi256_accent: rgb_to_ansi256(ar, ag, ab),
186            },
187        }
188    }
189
190    /// `"🦊 foxtrot-meadow"` — plain, no ANSI escapes. Safe in any output.
191    pub fn short(&self) -> String {
192        format!("{} {}", self.emoji, self.nickname)
193    }
194
195    /// `short()` wrapped in ANSI 256-color foreground escapes for the primary
196    /// color. Renders correctly in any terminal supporting 256 colors (the
197    /// universal lower bound — every modern emulator). For terminals without
198    /// color support, escapes will be visible-but-harmless.
199    pub fn colored(&self) -> String {
200        format!(
201            "\x1b[38;5;{}m{} {}\x1b[0m",
202            self.palette.ansi256_primary, self.emoji, self.nickname
203        )
204    }
205}
206
207/// v0.7.0-alpha.8 (review-fix #1): sanitize operator-chosen or peer-
208/// published display text (nickname or emoji) for safe terminal render.
209///
210/// Strips Unicode Control category chars (`is_control()` — covers C0,
211/// DEL, C1 including ESC U+001B which gates ANSI/OSC/CSI escape
212/// sequences), then caps length to `MAX_DISPLAY_CHARS` codepoints so a
213/// malicious peer can't ship a 10MB nickname that destroys the
214/// statusline layout.
215///
216/// Used at write time (`wire identity rename` rejects sanitization-
217/// reduced inputs as an error) and at read time (`Character::from_card`
218/// silently strips for defense-in-depth against pinned cards that
219/// pre-date this validation).
220pub const MAX_DISPLAY_CHARS: usize = 64;
221
222pub fn sanitize_display_text(s: &str) -> String {
223    s.chars()
224        .filter(|c| !c.is_control())
225        .take(MAX_DISPLAY_CHARS)
226        .collect()
227}
228
229/// HSL → RGB. h ∈ [0, 360), s ∈ [0, 1], l ∈ [0, 1]. Returns u8 triplet.
230/// Standard formula; no clamping needed when s/l are already in-range.
231fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
232    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
233    let h_prime = h / 60.0;
234    let x = c * (1.0 - ((h_prime % 2.0) - 1.0).abs());
235    let (r1, g1, b1) = match h_prime as i32 {
236        0 => (c, x, 0.0),
237        1 => (x, c, 0.0),
238        2 => (0.0, c, x),
239        3 => (0.0, x, c),
240        4 => (x, 0.0, c),
241        _ => (c, 0.0, x),
242    };
243    let m = l - c / 2.0;
244    let r = ((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
245    let g = ((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
246    let b = ((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8;
247    (r, g, b)
248}
249
250/// Nearest color in the ANSI 256 6×6×6 cube. Returns an index in 16..=231.
251fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
252    let q = |c: u8| -> u8 { ((c as u16 * 5 + 127) / 255) as u8 }; // round to 0..=5
253    16 + 36 * q(r) + 6 * q(g) + q(b)
254}
255
256/// ~256 short, neutral adjectives. Nature, abstract, texture, mood.
257/// v0.7.0-alpha.4: doubled from the alpha.1 set of 120 to widen the
258/// combinatorial space and reduce nickname collisions at scale.
259const ADJECTIVES: &[&str] = &[
260    "agate",
261    "alpine",
262    "amber",
263    "ancient",
264    "antique",
265    "arctic",
266    "ashen",
267    "auburn",
268    "autumn",
269    "azure",
270    "balmy",
271    "blithe",
272    "brave",
273    "breezy",
274    "briar",
275    "bright",
276    "brisk",
277    "bronze",
278    "brushed",
279    "bubbling",
280    "burnished",
281    "calm",
282    "candle",
283    "cedar",
284    "chestnut",
285    "chill",
286    "chipper",
287    "cinder",
288    "clay",
289    "clear",
290    "cliffside",
291    "cobalt",
292    "copper",
293    "coral",
294    "cordial",
295    "cosmic",
296    "crimson",
297    "crisp",
298    "crystal",
299    "curious",
300    "dapper",
301    "dappled",
302    "dawn",
303    "daydream",
304    "deep",
305    "delta",
306    "dewy",
307    "distant",
308    "drift",
309    "drowsy",
310    "dune",
311    "dusky",
312    "eager",
313    "echoing",
314    "ember",
315    "emerald",
316    "feral",
317    "ferny",
318    "festive",
319    "fjord",
320    "flaxen",
321    "fluted",
322    "fond",
323    "forest",
324    "foxtrot",
325    "fragrant",
326    "frosted",
327    "frosty",
328    "garnet",
329    "gentle",
330    "ginger",
331    "glacial",
332    "glassy",
333    "gleaming",
334    "glint",
335    "glossy",
336    "gold",
337    "graceful",
338    "granite",
339    "grove",
340    "hammered",
341    "harbor",
342    "hardy",
343    "hazel",
344    "heath",
345    "honey",
346    "humble",
347    "hush",
348    "indigo",
349    "ivory",
350    "jade",
351    "jaunty",
352    "juniper",
353    "keen",
354    "kelp",
355    "kindly",
356    "knit",
357    "lacquered",
358    "lapis",
359    "lavender",
360    "leaden",
361    "lichen",
362    "lilac",
363    "linen",
364    "lively",
365    "lonely",
366    "lucid",
367    "lunar",
368    "marble",
369    "marsh",
370    "meadow",
371    "mellow",
372    "merry",
373    "mild",
374    "minted",
375    "misted",
376    "misty",
377    "moonlit",
378    "morning",
379    "mossy",
380    "muted",
381    "neon",
382    "nimble",
383    "noble",
384    "north",
385    "ochre",
386    "olive",
387    "onyx",
388    "opal",
389    "orchid",
390    "outback",
391    "pearl",
392    "pearled",
393    "peat",
394    "petal",
395    "pewter",
396    "pine",
397    "placid",
398    "plucky",
399    "plum",
400    "polar",
401    "polished",
402    "poppy",
403    "prairie",
404    "primrose",
405    "prussian",
406    "purpled",
407    "quartz",
408    "quiet",
409    "quill",
410    "raven",
411    "reckless",
412    "redwood",
413    "restless",
414    "ribbon",
415    "river",
416    "rosemary",
417    "rosy",
418    "ruby",
419    "rusted",
420    "rustic",
421    "russet",
422    "saffron",
423    "sage",
424    "salt",
425    "sandy",
426    "satin",
427    "scarlet",
428    "sea",
429    "shaded",
430    "shadow",
431    "shimmer",
432    "shining",
433    "shore",
434    "silken",
435    "silver",
436    "skylit",
437    "slate",
438    "slatey",
439    "smoky",
440    "smolder",
441    "snowy",
442    "soft",
443    "solar",
444    "splendid",
445    "spruce",
446    "starry",
447    "steadfast",
448    "steady",
449    "sterling",
450    "stone",
451    "sturdy",
452    "sublime",
453    "summer",
454    "sunlit",
455    "sunny",
456    "supple",
457    "sweetbay",
458    "swift",
459    "tawny",
460    "teal",
461    "tender",
462    "terra",
463    "thistle",
464    "thrushlike",
465    "tidal",
466    "tinder",
467    "tinkling",
468    "topaz",
469    "torrid",
470    "tranquil",
471    "trillium",
472    "twilight",
473    "umber",
474    "valley",
475    "velvet",
476    "vernal",
477    "verdant",
478    "vesper",
479    "vibrant",
480    "violet",
481    "vivid",
482    "warm",
483    "warmer",
484    "weathered",
485    "westwind",
486    "whispered",
487    "wildflower",
488    "willow",
489    "winterly",
490    "windy",
491    "winter",
492    "wisp",
493    "withered",
494    "witty",
495    "woodland",
496    "woven",
497    "wren",
498    "wry",
499    "yarrow",
500    "yonder",
501    "zen",
502    "zephyr",
503];
504
505/// ~256 short, evocative nouns. Geographic features, weather, materials,
506/// flora, fauna, objects, light.
507/// v0.7.0-alpha.4: doubled from the alpha.1 set of 120.
508const NOUNS: &[&str] = &[
509    "anchor",
510    "ash",
511    "aspen",
512    "atlas",
513    "aurora",
514    "badger",
515    "bark",
516    "bay",
517    "bayou",
518    "beacon",
519    "beaver",
520    "bell",
521    "birch",
522    "bison",
523    "blossom",
524    "bough",
525    "branch",
526    "breeze",
527    "briar",
528    "brook",
529    "bud",
530    "bunting",
531    "burrow",
532    "butte",
533    "caldera",
534    "camellia",
535    "canyon",
536    "cardinal",
537    "caribou",
538    "cedar",
539    "chime",
540    "chinook",
541    "cinder",
542    "cirrus",
543    "cliff",
544    "cloudburst",
545    "comet",
546    "compass",
547    "copper",
548    "coral",
549    "cove",
550    "creek",
551    "crest",
552    "cricket",
553    "crow",
554    "cumulus",
555    "cyclone",
556    "cypress",
557    "dale",
558    "delta",
559    "dew",
560    "dewdrop",
561    "dolphin",
562    "dove",
563    "dragonfly",
564    "dune",
565    "dusk",
566    "eagle",
567    "ember",
568    "fern",
569    "field",
570    "finch",
571    "fjord",
572    "flame",
573    "flax",
574    "fleck",
575    "fog",
576    "foam",
577    "forest",
578    "fox",
579    "frost",
580    "garnet",
581    "gander",
582    "geode",
583    "geyser",
584    "glade",
585    "gleam",
586    "glen",
587    "glimmer",
588    "gorge",
589    "grove",
590    "hare",
591    "harbor",
592    "haze",
593    "headland",
594    "hearth",
595    "heath",
596    "heron",
597    "hollow",
598    "hummingbird",
599    "ibis",
600    "iris",
601    "ivy",
602    "jasmine",
603    "jasper",
604    "jay",
605    "juniper",
606    "kelp",
607    "kestrel",
608    "kettle",
609    "kingfisher",
610    "knoll",
611    "lagoon",
612    "lake",
613    "lantern",
614    "lark",
615    "laurel",
616    "leaf",
617    "leaflet",
618    "ledge",
619    "lichen",
620    "lily",
621    "linden",
622    "lion",
623    "loft",
624    "loon",
625    "lotus",
626    "lupin",
627    "lynx",
628    "magnolia",
629    "magpie",
630    "maple",
631    "marmot",
632    "marsh",
633    "meadow",
634    "meadowlark",
635    "mesa",
636    "mist",
637    "moor",
638    "moss",
639    "moth",
640    "mountain",
641    "narwhal",
642    "nettle",
643    "nightingale",
644    "nimbus",
645    "oak",
646    "ocean",
647    "ochre",
648    "opal",
649    "orchard",
650    "orchid",
651    "oriole",
652    "otter",
653    "owl",
654    "palm",
655    "pelican",
656    "petal",
657    "pine",
658    "pinion",
659    "plain",
660    "plateau",
661    "plover",
662    "pond",
663    "poppy",
664    "prairie",
665    "prism",
666    "puffin",
667    "quartz",
668    "quill",
669    "raindrop",
670    "rapid",
671    "raven",
672    "ravine",
673    "redwood",
674    "reef",
675    "ridge",
676    "river",
677    "robin",
678    "rook",
679    "rosemary",
680    "rowan",
681    "sable",
682    "saffron",
683    "sage",
684    "salmon",
685    "sand",
686    "sandbar",
687    "sandpiper",
688    "sapling",
689    "savanna",
690    "scrub",
691    "sea",
692    "shadow",
693    "shale",
694    "shard",
695    "sheaf",
696    "shore",
697    "shrub",
698    "sky",
699    "slate",
700    "snowdrop",
701    "snowfall",
702    "snowflake",
703    "sparrow",
704    "spindle",
705    "spire",
706    "spring",
707    "sprout",
708    "spruce",
709    "squall",
710    "starling",
711    "starshine",
712    "steppe",
713    "stone",
714    "stratus",
715    "summit",
716    "swallow",
717    "swift",
718    "tarn",
719    "tern",
720    "thaw",
721    "thicket",
722    "thistle",
723    "thrush",
724    "tide",
725    "tideline",
726    "tinder",
727    "topaz",
728    "tournesol",
729    "trillium",
730    "trout",
731    "tundra",
732    "twilight",
733    "twig",
734    "valley",
735    "vesper",
736    "vine",
737    "violet",
738    "wallaby",
739    "warbler",
740    "wave",
741    "weasel",
742    "whirlwind",
743    "willow",
744    "wisp",
745    "wolf",
746    "wood",
747    "wren",
748    "yarrow",
749    "yew",
750    "zephyr",
751];
752
753/// ~144 curated emojis. All single Unicode codepoint —
754/// no flags, no skin tone, no ZWJ family/profession sequences. Render
755/// consistently across iTerm, Terminal.app, Alacritty, kitty, GNOME
756/// Terminal, Konsole, and tmux.
757/// v0.7.0-alpha.4: more than doubled from the alpha.1 set of 64. Themed
758/// across animals (fauna heavy because they're the most evocative),
759/// flora, weather/sky, food, music, and abstract symbols.
760const EMOJIS: &[&str] = &[
761    // Animals — mammals
762    "🦊", "🐺", "🐻", "🐅", "🐆", "🦓", "🦒", "🦌", "🦘", "🐇", "🦔", "🦣", "🦏", "🐈", "🐱", "🐶",
763    "🐰", "🦦", "🦥", "🦡", "🦨", "🦄", "🐴", "🐗", "🐘", "🦬", "🦫", "🐪", "🦙", "🐭", "🐹", "🐀",
764    // Animals — birds
765    "🦅", "🦉", "🦢", "🦩", "🐧", "🦃", "🦚", "🦜", "🦤", "🦆", "🐓", "🐔", "🐦", "🪶",
766    // Animals — reptiles + amphibians
767    "🐊", "🦎", "🐍", "🐢", "🦕", "🦖", "🐸", // Animals — sea
768    "🐙", "🐬", "🐳", "🐋", "🐡", "🦈", "🦭", "🐟", "🐠", "🦀", "🦞", "🦐", "🐚",
769    // Animals — bugs
770    "🐝", "🦋", "🐌", "🐞", "🦗", "🕷", "🦂", // Plants — trees
771    "🌲", "🌳", "🌴", "🌵", "🌱", "🌿", "🍃", "🍀", "🍁", "🍂", // Plants — flowers
772    "🌷", "🌸", "🌺", "🌻", "🌼", "🌹", "🪻", "🪷", "🍄", // Plants — fruits
773    "🍇", "🍈", "🍉", "🍊", "🍋", "🍌", "🍍", "🥭", "🍎", "🍏", "🍐", "🍑", "🍒", "🍓", "🫐", "🥝",
774    // Weather + sky
775    "🌊", "🌋", "🌙", "🌟", "🌈", "🔥", "❄", "💧", "⚡", "☀", "☁", "⛄",
776    // Light + abstract
777    "💎", "🪄", "🔮", "🧿", "🌠", // Music
778    "🎵", "🎶", "🎷", "🎸", "🎹", "🎺", "🎻", "🥁", "🪕", "🪈", // Objects + travel
779    "⚓", "🧭", "🏺", "🪴", "🗿", "🛡", "🗝", "🎲", "🎭", "🎨", "🎯", "🪐",
780];
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use serde_json::json;
786    use std::collections::HashSet;
787
788    #[test]
789    fn deterministic_same_did() {
790        let a = Character::from_did("did:wire:paul-a1b2c3d4");
791        let b = Character::from_did("did:wire:paul-a1b2c3d4");
792        assert_eq!(a, b);
793    }
794
795    #[test]
796    fn different_dids_differ() {
797        let a = Character::from_did("did:wire:paul-a1b2c3d4");
798        let b = Character::from_did("did:wire:paul-e5f6a7b8");
799        assert_ne!(a, b);
800    }
801
802    #[test]
803    fn nickname_is_hyphenated_pair() {
804        let c = Character::from_did("did:wire:test-deadbeef");
805        let parts: Vec<&str> = c.nickname.split('-').collect();
806        assert_eq!(parts.len(), 2);
807        assert!(parts[0].chars().all(|ch| ch.is_ascii_lowercase()));
808        assert!(parts[1].chars().all(|ch| ch.is_ascii_lowercase()));
809    }
810
811    #[test]
812    fn emoji_is_in_curated_set() {
813        let c = Character::from_did("did:wire:test-cafebabe");
814        assert!(EMOJIS.contains(&c.emoji.as_str()));
815    }
816
817    #[test]
818    fn palette_hex_is_well_formed() {
819        let c = Character::from_did("did:wire:test-12345678");
820        assert!(c.palette.primary_hex.starts_with('#'));
821        assert_eq!(c.palette.primary_hex.len(), 7);
822        assert!(c.palette.accent_hex.starts_with('#'));
823        assert_eq!(c.palette.accent_hex.len(), 7);
824    }
825
826    #[test]
827    fn ansi256_in_cube_range() {
828        let c = Character::from_did("did:wire:test-87654321");
829        assert!((16..=231).contains(&c.palette.ansi256_primary));
830        assert!((16..=231).contains(&c.palette.ansi256_accent));
831    }
832
833    #[test]
834    fn short_format() {
835        let c = Character::from_did("did:wire:fixed-seed-here");
836        let short = c.short();
837        assert!(short.contains(&c.emoji));
838        assert!(short.contains(&c.nickname));
839        assert_eq!(short, format!("{} {}", c.emoji, c.nickname));
840    }
841
842    #[test]
843    fn colored_includes_ansi_escape() {
844        let c = Character::from_did("did:wire:colored-test");
845        let colored = c.colored();
846        assert!(colored.starts_with("\x1b[38;5;"));
847        assert!(colored.ends_with("\x1b[0m"));
848        assert!(colored.contains(&c.nickname));
849    }
850
851    #[test]
852    fn no_nickname_collisions_10k_samples() {
853        // 14400 possible nickname combinations; in 10k random DIDs we'll
854        // see *some* collisions by birthday paradox (~3500 expected). Check
855        // that *characters* (full triple) are unique enough — collisions in
856        // (nickname, emoji, primary_hex) below 1% across 10k samples.
857        let mut chars: HashSet<(String, String, String)> = HashSet::new();
858        let mut collisions = 0;
859        for i in 0..10_000 {
860            let did = format!("did:wire:test-{:08x}", i);
861            let c = Character::from_did(&did);
862            let key = (
863                c.nickname.clone(),
864                c.emoji.clone(),
865                c.palette.primary_hex.clone(),
866            );
867            if !chars.insert(key) {
868                collisions += 1;
869            }
870        }
871        assert!(
872            collisions < 100,
873            "saw {collisions} character-triple collisions in 10k samples (>1%)"
874        );
875    }
876
877    #[test]
878    fn word_lists_have_expected_size() {
879        assert!(ADJECTIVES.len() >= 100, "adjective list too small");
880        assert!(NOUNS.len() >= 100, "noun list too small");
881        assert!(EMOJIS.len() >= 50, "emoji list too small");
882    }
883
884    #[test]
885    fn no_duplicate_words() {
886        let adj_set: HashSet<&&str> = ADJECTIVES.iter().collect();
887        assert_eq!(adj_set.len(), ADJECTIVES.len(), "duplicate adjective");
888        let noun_set: HashSet<&&str> = NOUNS.iter().collect();
889        assert_eq!(noun_set.len(), NOUNS.len(), "duplicate noun");
890        let emoji_set: HashSet<&&str> = EMOJIS.iter().collect();
891        assert_eq!(emoji_set.len(), EMOJIS.len(), "duplicate emoji");
892    }
893
894    #[test]
895    fn hsl_to_rgb_known_values() {
896        // Red: H=0, S=1, L=0.5 → (255, 0, 0)
897        let (r, g, b) = hsl_to_rgb(0.0, 1.0, 0.5);
898        assert_eq!(r, 255);
899        assert_eq!(g, 0);
900        assert_eq!(b, 0);
901        // Green: H=120, S=1, L=0.5 → (0, 255, 0)
902        let (r, g, b) = hsl_to_rgb(120.0, 1.0, 0.5);
903        assert_eq!(r, 0);
904        assert_eq!(g, 255);
905        assert_eq!(b, 0);
906        // Blue: H=240, S=1, L=0.5 → (0, 0, 255)
907        let (r, g, b) = hsl_to_rgb(240.0, 1.0, 0.5);
908        assert_eq!(r, 0);
909        assert_eq!(g, 0);
910        assert_eq!(b, 255);
911    }
912
913    #[test]
914    fn sanitize_strips_ansi_escape() {
915        // The core attack vector: peer publishes display.nickname with
916        // ESC ] 0 ; pwned BEL → terminal renames window. ESC + BEL are
917        // U+001B / U+0007 (control chars); the `]` and `;` and visible
918        // text are printable and survive but are harmless without ESC.
919        let out = sanitize_display_text("\x1b]0;owned\x07");
920        assert!(!out.contains('\x1b'), "ESC must be stripped: {out:?}");
921        assert!(!out.contains('\x07'), "BEL must be stripped: {out:?}");
922        // The visible-but-now-harmless residue.
923        assert_eq!(out, "]0;owned");
924        // CSI sequences also defanged (ESC gone).
925        let out2 = sanitize_display_text("\x1b[2J\x1b[H");
926        assert!(!out2.contains('\x1b'));
927        assert_eq!(out2, "[2J[H");
928        // Newlines / tabs / DEL also stripped.
929        assert_eq!(sanitize_display_text("hello\nworld"), "helloworld");
930        assert_eq!(sanitize_display_text("a\tb\x7fc"), "abc");
931    }
932
933    #[test]
934    fn sanitize_preserves_unicode_emoji_and_text() {
935        assert_eq!(
936            sanitize_display_text("🦊 foxtrot-meadow"),
937            "🦊 foxtrot-meadow"
938        );
939        assert_eq!(sanitize_display_text("café résumé"), "café résumé");
940    }
941
942    #[test]
943    fn sanitize_caps_length() {
944        let long = "a".repeat(200);
945        let out = sanitize_display_text(&long);
946        assert_eq!(out.chars().count(), MAX_DISPLAY_CHARS);
947    }
948
949    #[test]
950    fn from_card_with_empty_did_returns_unknown_sentinel() {
951        // The exact silent-collision scenario from review-fix #8: a
952        // pinned peer card with null/missing/empty did used to collapse
953        // every such peer onto from_did("") — same character for all.
954        let card = json!({"handle": "broken"});
955        let c = Character::from_card(&card);
956        assert_eq!(c.nickname, "unknown-peer");
957        assert_eq!(c.emoji, "❓");
958    }
959
960    #[test]
961    fn from_card_with_null_did_returns_unknown_sentinel() {
962        let card = json!({"did": null, "handle": "broken"});
963        let c = Character::from_card(&card);
964        assert_eq!(c.nickname, "unknown-peer");
965    }
966
967    #[test]
968    fn from_card_strips_escape_from_published_nickname() {
969        // Defense-in-depth: even if a malicious peer signed a card with
970        // ANSI escapes in display.nickname before this validation
971        // shipped, we strip them at read time so the operator's
972        // terminal stays safe.
973        let card = json!({
974            "did": "did:wire:malicious-deadbeef",
975            "display": {"nickname": "\x1b]0;OWNED\x07evil", "emoji": "🦊"},
976        });
977        let c = Character::from_card(&card);
978        // ESC + OSC delimiters removed; what's left is the visible text.
979        assert!(!c.nickname.contains('\x1b'));
980        assert!(!c.nickname.contains('\x07'));
981        assert!(c.nickname.contains("OWNED")); // visible text preserved
982        assert_eq!(c.emoji, "🦊");
983    }
984
985    #[test]
986    fn from_card_with_published_override_uses_it() {
987        let card = json!({
988            "did": "did:wire:friend-12345678",
989            "display": {"nickname": "the-forge", "emoji": "🔨"},
990        });
991        let c = Character::from_card(&card);
992        assert_eq!(c.nickname, "the-forge");
993        assert_eq!(c.emoji, "🔨");
994    }
995
996    #[test]
997    fn from_card_without_display_falls_back_to_did() {
998        let card = json!({"did": "did:wire:friend-12345678"});
999        let c = Character::from_card(&card);
1000        let auto = Character::from_did("did:wire:friend-12345678");
1001        assert_eq!(c, auto);
1002    }
1003
1004    #[test]
1005    fn rgb_to_ansi256_matches_cube() {
1006        // Pure black corner of cube → 16. Pure white → 231.
1007        assert_eq!(rgb_to_ansi256(0, 0, 0), 16);
1008        assert_eq!(rgb_to_ansi256(255, 255, 255), 231);
1009        // Red corner (255, 0, 0) → 16 + 5*36 = 196.
1010        assert_eq!(rgb_to_ansi256(255, 0, 0), 196);
1011        // Green corner (0, 255, 0) → 16 + 5*6 = 46.
1012        assert_eq!(rgb_to_ansi256(0, 255, 0), 46);
1013    }
1014}