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/// Model families distinctive enough to match anywhere in a name, so `gpt5`,
58/// `claude-opus` and `mi-gemini` all resolve.
59///
60/// This is a first guess, not a registry. `fur avatar <name> --kind ai` is the
61/// authoritative answer and is recorded in the archive; the list only spares
62/// people the flag in the common case. It therefore does not need to be
63/// exhaustive, and is deliberately not user-configurable — a second, editable
64/// source of truth would silently reclassify existing avatars when edited.
65pub const AI_NAME_FRAGMENTS: &[&str] = &[
66    "gpt",
67    "claude",
68    "gemini",
69    "bard",
70    "grok",
71    "copilot",
72    "llama",
73    "mistral",
74    "deepseek",
75    "qwen",
76];
77
78/// Generic words that mean "not a person", matched as whole tokens.
79///
80/// these are substring-unsafe — `ai` appears in `claire` and `raina`,
81/// `bot` in `robotics` — so they are split on non-alphanumerics and compared
82/// whole. Anything added here must survive that test.
83pub const AI_NAME_TOKENS: &[&str] = &[
84    "ai", "ia", "bot", "llm", "agent", "assistant", "asistente", "model", "modelo",
85];
86
87/// Names that clearly belong to a model or agent.
88pub fn is_bot_name(name: &str) -> bool {
89    let n = name.to_lowercase();
90
91    if AI_NAME_FRAGMENTS.iter().any(|m| n.contains(m)) {
92        return true;
93    }
94
95    n.split(|c: char| !c.is_alphanumeric())
96        .any(|tok| AI_NAME_TOKENS.contains(&tok))
97}
98
99/// A sensible starting emoji for a name, so suggestions look considered rather
100/// than arbitrary. Falls back to a neutral person glyph.
101pub fn get_random_emoji_for_name(name: &str) -> String {
102    if is_bot_name(name) {
103        return "🤖".to_string();
104    }
105
106    if let Some(emoji) = role_emoji(name) {
107        return emoji.to_string();
108    }
109
110    let pool = ["👤", "🧑", "👔", "🙂"];
111    pool.choose(&mut rand::rng()).unwrap_or(&"👤").to_string()
112}
113
114/// Roles that recur often enough to be worth naming directly.
115fn role_emoji(name: &str) -> Option<&'static str> {
116    const ROLES: &[(&str, &str)] = &[
117        ("me", MAIN_EMOJI),
118        ("self", MAIN_EMOJI),
119        ("user", MAIN_EMOJI),
120        ("boss", "💼"),
121        ("manager", "💼"),
122        ("hr", "📋"),
123        ("therapist", "🛋"),
124        ("lawyer", "⚖"),
125        ("doctor", "🩺"),
126        ("client", "🤝"),
127        ("team", "👥"),
128        ("cat", "🐱"),
129        ("dog", "🐶"),
130    ];
131
132    let key = name.trim().to_lowercase();
133
134    ROLES
135        .iter()
136        .find(|(candidate, _)| key == *candidate)
137        .map(|(_, emoji)| *emoji)
138}
139
140/// Optional per-avatar metadata, stored under the reserved `meta` key.
141///
142/// ```json
143/// { "main": "andrew", "andrew": "🦊", "claude": "✨",
144///   "meta": { "andrew": {"role": "Investigador Principal"},
145///             "claude": {"kind": "ai"} } }
146/// ```
147///
148/// viceroy: deliberately additive rather than a schema bump. `load_avatars`,
149/// `resolve_avatar` and all six call sites keep working untouched, and a
150/// project that never sets a role writes the same bytes it always did.
151/// `role` is absent when unset — never null, never "", never "?".
152pub const META_KEY: &str = "meta";
153
154/// Role for an avatar, when one has been set.
155pub fn role_of(avatars: &Value, name: &str) -> Option<String> {
156    avatars
157        .get(META_KEY)?
158        .get(name)?
159        .get("role")?
160        .as_str()
161        .filter(|s| !s.trim().is_empty())
162        .map(|s| s.to_string())
163}
164
165/// "human" or "ai". Explicit metadata wins; otherwise inferred from the name,
166/// which is what `is_bot_name` already exists to answer.
167pub fn kind_of(avatars: &Value, name: &str) -> String {
168    let explicit = avatars
169        .get(META_KEY)
170        .and_then(|m| m.get(name))
171        .and_then(|entry| entry.get("kind"))
172        .and_then(|v| v.as_str());
173
174    match explicit {
175        Some("ai") => "ai".to_string(),
176        Some("human") => "human".to_string(),
177        _ if is_bot_name(name) => "ai".to_string(),
178        _ => "human".to_string(),
179    }
180}
181
182/// Set or clear one metadata field. Clearing removes the key entirely, and an
183/// avatar left with no metadata is removed from `meta` so the file does not
184/// accumulate empty objects.
185pub fn set_meta(avatars: &mut Value, name: &str, field: &str, value: Option<&str>) {
186    if !avatars.is_object() {
187        *avatars = json!({});
188    }
189    if avatars.get(META_KEY).map(|m| !m.is_object()).unwrap_or(true) {
190        avatars[META_KEY] = json!({});
191    }
192
193    match value {
194        Some(v) if !v.trim().is_empty() => {
195            if avatars[META_KEY].get(name).is_none() {
196                avatars[META_KEY][name] = json!({});
197            }
198            avatars[META_KEY][name][field] = json!(v.trim());
199        }
200        _ => {
201            if let Some(entry) = avatars[META_KEY].get_mut(name) {
202                if let Some(map) = entry.as_object_mut() {
203                    map.remove(field);
204                }
205            }
206        }
207    }
208
209    let empty = avatars[META_KEY]
210        .get(name)
211        .and_then(|e| e.as_object())
212        .map(|m| m.is_empty())
213        .unwrap_or(false);
214
215    if empty {
216        if let Some(meta) = avatars[META_KEY].as_object_mut() {
217            meta.remove(name);
218        }
219    }
220
221    if avatars[META_KEY]
222        .as_object()
223        .map(|m| m.is_empty())
224        .unwrap_or(false)
225    {
226        if let Some(map) = avatars.as_object_mut() {
227            map.remove(META_KEY);
228        }
229    }
230}
231
232/// Human-readable name for an avatar, when one has been set.
233///
234/// The avatar name is the handle that appears in every message marker and is
235/// stable; this is display-only and never enters the document format.
236pub fn display_name_of(avatars: &Value, name: &str) -> Option<String> {
237    avatars
238        .get(META_KEY)?
239        .get(name)?
240        .get("name")?
241        .as_str()
242        .filter(|s| !s.trim().is_empty())
243        .map(|s| s.to_string())
244}
245
246/// True for keys that are not avatar names.
247pub fn is_reserved_key(key: &str) -> bool {
248    key == "main" || key == META_KEY
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn recognises_models() {
257        assert!(is_bot_name("gpt5"));
258        assert!(is_bot_name("claude"));
259        assert!(is_bot_name("my-ai"));
260        assert!(is_bot_name("research agent"));
261    }
262
263    #[test]
264    fn recognises_spanish_and_current_models() {
265        assert!(is_bot_name("asistente"));
266        assert!(is_bot_name("mi-ia"));
267        assert!(is_bot_name("llama3"));
268        assert!(is_bot_name("deepseek-r1"));
269        assert!(!is_bot_name("maria"));
270        assert!(!is_bot_name("diana"));
271    }
272
273    #[test]
274    fn does_not_mistake_people_for_bots() {
275        // Each of these contains "ai" or "bot" as a substring.
276        assert!(!is_bot_name("claire"));
277        assert!(!is_bot_name("raina"));
278        assert!(!is_bot_name("robotics_lab"));
279        assert!(!is_bot_name("management"));
280    }
281
282    #[test]
283    fn roles_get_stable_emoji() {
284        assert_eq!(get_random_emoji_for_name("hr"), "📋");
285        assert_eq!(get_random_emoji_for_name("me"), MAIN_EMOJI);
286    }
287}