Skip to main content

fur_cli/avatars/
mod.rs

1//! Avatar identity: names, emoji, and the `main` pointer.
2//!
3//! viceroy: this lived in `src/frs/` but is not FurScript. `resolve_avatar` is
4//! called by `tree`, `status`, and the renderers; `load_avatars` by `new`,
5//! `jot`, `avatar`, and `onboard`. Only `frs::parser` uses it from inside the
6//! DSL. `frs::avatars` still resolves here via a re-export, so no existing
7//! import path breaks.
8
9pub mod emojis;
10
11use rand::prelude::IndexedRandom;
12use serde_json::{json, Value};
13use std::fs;
14use std::path::Path;
15
16/// Shown when an avatar has no emoji assigned, and written by `fur rebuild`
17/// for every avatar it recovers from documents.
18pub const PLACEHOLDER: &str = "🐾";
19
20/// Reserved for the main avatar.
21pub const MAIN_EMOJI: &str = "🦊";
22
23pub fn load_avatars() -> Value {
24    let path = Path::new(".fur/avatars.json");
25    if path.exists() {
26        let content = fs::read_to_string(path).unwrap_or_default();
27        serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
28    } else {
29        json!({})
30    }
31}
32
33pub fn save_avatars(avatars: &Value) {
34    let path = Path::new(".fur/avatars.json");
35    if let Ok(serialized) = serde_json::to_string_pretty(avatars) {
36        let _ = fs::write(path, serialized);
37    }
38}
39
40pub fn resolve_avatar(avatars: &Value, key: &str) -> (String, String) {
41    // If key matches a known avatar name → return (name, emoji)
42    if let Some(emoji) = avatars.get(key).and_then(|v| v.as_str()) {
43        return (key.to_string(), emoji.to_string());
44    }
45
46    // If key looks like an emoji already → reverse-lookup name
47    if let Some((name, _)) = avatars
48        .as_object()
49        .and_then(|map| map.iter().find(|(_, v)| v.as_str() == Some(key)))
50    {
51        return (name.clone(), key.to_string());
52    }
53
54    (key.to_string(), PLACEHOLDER.to_string()) // fallback
55}
56
57/// Names that clearly belong to a model or agent.
58///
59/// viceroy: the short markers used to be plain substring tests, so `ai` matched
60/// `claire`, `raina`, and `mail`, and `bot` matched `robotics`. Short markers
61/// are matched as whole tokens now; only the distinctive long ones stay
62/// substring matches, so `gpt5` and `claude-3` still resolve.
63pub fn is_bot_name(name: &str) -> bool {
64    let n = name.to_lowercase();
65
66    const SUBSTRING_MARKERS: [&str; 6] = ["gpt", "claude", "gemini", "bard", "grok", "copilot"];
67
68    if SUBSTRING_MARKERS.iter().any(|m| n.contains(m)) {
69        return true;
70    }
71
72    const TOKEN_MARKERS: [&str; 7] = ["ai", "bot", "llm", "agent", "assistant", "model", "llama"];
73
74    n.split(|c: char| !c.is_alphanumeric())
75        .any(|tok| TOKEN_MARKERS.contains(&tok))
76}
77
78/// A sensible starting emoji for a name, so suggestions look considered rather
79/// than arbitrary. Falls back to a neutral person glyph.
80pub fn get_random_emoji_for_name(name: &str) -> String {
81    if is_bot_name(name) {
82        return "🤖".to_string();
83    }
84
85    if let Some(emoji) = role_emoji(name) {
86        return emoji.to_string();
87    }
88
89    let pool = ["👤", "🧑", "👔", "🙂"];
90    pool.choose(&mut rand::rng()).unwrap_or(&"👤").to_string()
91}
92
93/// Roles that recur often enough to be worth naming directly.
94fn role_emoji(name: &str) -> Option<&'static str> {
95    const ROLES: &[(&str, &str)] = &[
96        ("me", MAIN_EMOJI),
97        ("self", MAIN_EMOJI),
98        ("user", MAIN_EMOJI),
99        ("boss", "💼"),
100        ("manager", "💼"),
101        ("hr", "📋"),
102        ("therapist", "🛋"),
103        ("lawyer", "⚖"),
104        ("doctor", "🩺"),
105        ("client", "🤝"),
106        ("team", "👥"),
107        ("cat", "🐱"),
108        ("dog", "🐶"),
109    ];
110
111    let key = name.trim().to_lowercase();
112
113    ROLES
114        .iter()
115        .find(|(candidate, _)| key == *candidate)
116        .map(|(_, emoji)| *emoji)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn recognises_models() {
125        assert!(is_bot_name("gpt5"));
126        assert!(is_bot_name("claude"));
127        assert!(is_bot_name("my-ai"));
128        assert!(is_bot_name("research agent"));
129    }
130
131    #[test]
132    fn does_not_mistake_people_for_bots() {
133        // Each of these contains "ai" or "bot" as a substring.
134        assert!(!is_bot_name("claire"));
135        assert!(!is_bot_name("raina"));
136        assert!(!is_bot_name("robotics_lab"));
137        assert!(!is_bot_name("management"));
138    }
139
140    #[test]
141    fn roles_get_stable_emoji() {
142        assert_eq!(get_random_emoji_for_name("hr"), "📋");
143        assert_eq!(get_random_emoji_for_name("me"), MAIN_EMOJI);
144    }
145}