diff --git a/Cargo.lock b/Cargo.lock index a4ed22f..68e0d57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,7 +68,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -79,7 +79,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -575,7 +575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -684,6 +684,7 @@ dependencies = [ "chrono", "clap", "crossterm", + "fuzzy-matcher", "image", "insta", "ratatui", @@ -764,6 +765,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2142,7 +2152,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2384,7 +2394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2490,7 +2500,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2596,6 +2606,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.45" diff --git a/Cargo.toml b/Cargo.toml index b2c51ca..6ecc238 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ base64 = "0.22" # Async (bridge subprocess I/O) tokio = { version = "1", features = ["sync", "rt", "macros", "io-util", "process", "time"] } +fuzzy-matcher = "0.3.7" [build-dependencies] chrono = "0.4" diff --git a/src/lib.rs b/src/lib.rs index 41cf27d..220f31c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod petname; pub mod portrait; pub mod protocol; pub mod protocol_ext; +pub mod resolve; pub mod session; pub mod session_cmd; pub mod statusline; diff --git a/src/main.rs b/src/main.rs index 4847992..4947885 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use forestage::config; use forestage::download; use forestage::persona; use forestage::portrait; +use forestage::resolve; use forestage::session; use forestage::session_cmd; use forestage::tui; @@ -237,8 +238,12 @@ enum SessionAction { #[derive(Subcommand)] enum PersonaAction { - /// List available themes - List, + /// List themes, or characters within a theme + List { + /// Theme slug (or fuzzy fragment) — list this theme's characters. + /// Omit to list all themes. + theme: Option, + }, /// Show theme details (or a single character card with --agent) Show { @@ -285,6 +290,12 @@ fn truncate_one_line(s: &str, max: usize) -> String { fn main() -> anyhow::Result<()> { let cli = Cli::parse(); + // Fuzzy-resolve --theme and --persona before they reach the config + // merge. Two-phase: theme narrows persona; persona back-propagates + // theme when theme can't be resolved. Warnings on stderr. + let (resolved_theme, resolved_persona) = + resolve::resolve_theme_and_persona(cli.theme.as_deref(), cli.persona.as_deref())?; + // Build CLI overrides table let mut overrides = toml::Table::new(); { @@ -299,10 +310,10 @@ fn main() -> anyhow::Result<()> { overrides.insert("session".to_string(), toml::Value::Table(session_overrides)); } let mut persona_overrides = toml::Table::new(); - if let Some(theme) = &cli.theme { + if let Some(theme) = &resolved_theme { persona_overrides.insert("theme".to_string(), toml::Value::String(theme.clone())); } - if let Some(persona) = &cli.persona { + if let Some(persona) = &resolved_persona { persona_overrides.insert( "character".to_string(), toml::Value::String(persona.clone()), @@ -422,14 +433,43 @@ fn main() -> anyhow::Result<()> { } Some(Commands::Persona { action }) => match action { - PersonaAction::List => { - let themes = persona::list_themes(); - println!("{} themes available:", themes.len()); - for slug in &themes { - if let Ok(theme) = persona::load_theme(slug) { - println!(" {:<30} {}", slug, theme.theme.description); - } else { - println!(" {slug}"); + PersonaAction::List { theme: theme_arg } => { + if let Some(q) = theme_arg { + // List characters in a specific theme (fuzzy-resolve the theme slug). + let theme_slug = match resolve::match_theme(&q).picked() { + Some(s) => s, + None => { + eprintln!("forestage: theme '{q}' not found"); + std::process::exit(2); + } + }; + let theme = persona::load_theme(&theme_slug)?; + let mut chars: Vec<_> = theme.characters.iter().collect(); + chars.sort_by_key(|(k, _)| k.as_str()); + println!( + "{} ({}) — {} characters:", + theme.theme.name, + theme_slug, + chars.len() + ); + for (slug, c) in chars { + println!( + " {:<40} {} — {}", + slug, + c.character, + truncate_one_line(&c.style, 60) + ); + } + } else { + // List all themes. + let themes = persona::list_themes(); + println!("{} themes available:", themes.len()); + for slug in &themes { + if let Ok(theme) = persona::load_theme(slug) { + println!(" {:<30} {}", slug, theme.theme.description); + } else { + println!(" {slug}"); + } } } } @@ -442,6 +482,10 @@ fn main() -> anyhow::Result<()> { portrait_size, } => { let cfg = config::load_config(cli_overrides)?; + // Fuzzy-resolve the theme slug so 'persona show disc' works. + let name = resolve::match_theme(&name).picked().ok_or_else(|| { + anyhow::anyhow!("theme '{name}' not found — try 'forestage persona list'") + })?; let theme = persona::load_theme(&name)?; let Some(agent_slug) = agent else { @@ -476,6 +520,14 @@ fn main() -> anyhow::Result<()> { let _ = download::ensure_portraits(&name, &cfg.portrait); } + // Fuzzy-resolve the character slug within the theme roster. + let agent_slug = resolve::match_character_in_theme(&agent_slug, &theme) + .picked() + .ok_or_else(|| { + anyhow::anyhow!( + "character '{agent_slug}' not found in theme '{name}' — try 'forestage persona list {name}'" + ) + })?; let character_data = persona::get_character(&theme, &agent_slug)?; let portraits = portrait::resolve_portrait(&name, character_data); diff --git a/src/resolve.rs b/src/resolve.rs new file mode 100644 index 0000000..1dc3a40 --- /dev/null +++ b/src/resolve.rs @@ -0,0 +1,466 @@ +//! Fuzzy resolution for --theme and --persona CLI inputs. +//! +//! Under the B14 agent taxonomy there are ~100 themes and ~1050 characters. +//! Exact-slug-only lookup is tedious; this module lets users type fragments +//! (kubectl-style partial IDs plus fzf-style subsequence match) and get the +//! intended slug back. +//! +//! Design (see aae-orc-jwqz): +//! +//! Per-identifier resolution order (`match_slug`): +//! 1. Exact slug match. +//! 2. Unique case-insensitive prefix match. +//! 3. Fuzzy subsequence match (nucleo-matcher): +//! a. Above min-score AND top score beats runner-up by score-gap → +//! unambiguous; use top match. +//! b. Above min-score AND tied with runner-up → ambiguous; use top +//! but emit stderr warning with top 5 candidates. +//! c. No candidate above min-score → NotFound (caller errors with +//! top candidates). +//! +//! Two-phase theme+persona resolver (`resolve_theme_and_persona`): +//! * Resolve `--theme` first. When it succeeds, narrow `--persona` +//! matching to that theme's ~10 characters — ~100× fewer candidates +//! drops ambiguity to near-zero. +//! * If `--theme` is missing or fails to resolve, and `--persona` is +//! given, search characters across every theme and back-propagate the +//! matched character's home theme. + +use std::cmp::Reverse; +use std::io::Write; + +use fuzzy_matcher::FuzzyMatcher; +use fuzzy_matcher::skim::SkimMatcherV2; + +use crate::error::{ForestageError, Result}; +use crate::persona::{self, ThemeFile}; + +/// Minimum fuzzy score we'll accept. `fuzzy-matcher` returns scores +/// on the order of tens-to-low-hundreds for reasonable matches; below +/// this we treat the result as noise. +const MIN_FUZZY_SCORE: i64 = 40; + +/// Score gap between rank-1 and rank-2 that makes a fuzzy match +/// unambiguous. Below this the caller still proceeds with rank-1 but +/// emits a disambiguation warning on stderr. +const FUZZY_AMBIGUITY_GAP: i64 = 15; + +/// How many candidates we include in "did you mean?" output. +const MAX_CANDIDATES_SHOWN: usize = 5; + +/// Resolution outcome for a single fuzzy lookup. +#[derive(Debug)] +pub enum MatchResult { + /// Exact slug match — caller should use with no ceremony. + Exact(T), + /// Unique prefix match — one candidate starts with the query. + Prefix(T), + /// Fuzzy match, unambiguous (rank-1 beats rank-2 by the gap). + FuzzyUnique(T), + /// Fuzzy match, ambiguous — caller should warn with candidates but + /// proceed with the top match. + FuzzyAmbiguous { top: T, candidates: Vec }, + /// No candidate met the minimum score. Caller should error and show + /// the top candidates (may be empty if nothing matched at all). + NotFound { candidates: Vec }, +} + +impl MatchResult { + /// Pick the resolved value, if any. `NotFound` yields None. + pub fn picked(&self) -> Option { + match self { + MatchResult::Exact(v) + | MatchResult::Prefix(v) + | MatchResult::FuzzyUnique(v) + | MatchResult::FuzzyAmbiguous { top: v, .. } => Some(v.clone()), + MatchResult::NotFound { .. } => None, + } + } +} + +/// Match a free-form query against a slice of canonical slugs. +/// +/// Empty query returns `NotFound` with no candidates; callers should +/// skip calling this when no user input exists. +pub fn match_slug(query: &str, candidates: &[String]) -> MatchResult { + if query.is_empty() || candidates.is_empty() { + return MatchResult::NotFound { + candidates: Vec::new(), + }; + } + + // 1. Exact match (case-insensitive). + for c in candidates { + if c.eq_ignore_ascii_case(query) { + return MatchResult::Exact(c.clone()); + } + } + + // 2. Unique prefix match. + let q_lower = query.to_ascii_lowercase(); + let prefix_hits: Vec<&String> = candidates + .iter() + .filter(|c| c.to_ascii_lowercase().starts_with(&q_lower)) + .collect(); + if prefix_hits.len() == 1 { + return MatchResult::Prefix(prefix_hits[0].clone()); + } + + // 3. Fuzzy subsequence via fuzzy-matcher (skim's matcher — MIT). + let matcher = SkimMatcherV2::default().ignore_case(); + let mut scored: Vec<(String, i64)> = candidates + .iter() + .filter_map(|c| matcher.fuzzy_match(c, query).map(|s| (c.clone(), s))) + .collect(); + scored.sort_by_key(|(_, s)| Reverse(*s)); + + // Filter to candidates above the minimum score. + let qualifying: Vec<&(String, i64)> = scored + .iter() + .filter(|(_, s)| *s >= MIN_FUZZY_SCORE) + .collect(); + if qualifying.is_empty() { + let fallback: Vec = scored + .into_iter() + .take(MAX_CANDIDATES_SHOWN) + .map(|(c, _)| c) + .collect(); + return MatchResult::NotFound { + candidates: fallback, + }; + } + + let top = qualifying[0].0.clone(); + let top_score = qualifying[0].1; + let second_score = qualifying.get(1).map(|(_, s)| *s).unwrap_or(0); + + if top_score.saturating_sub(second_score) >= FUZZY_AMBIGUITY_GAP { + return MatchResult::FuzzyUnique(top); + } + + let candidates: Vec = qualifying + .into_iter() + .take(MAX_CANDIDATES_SHOWN) + .map(|(c, _)| c.clone()) + .collect(); + MatchResult::FuzzyAmbiguous { top, candidates } +} + +/// Resolve a theme query against the embedded theme slugs. +pub fn match_theme(query: &str) -> MatchResult { + let themes = persona::list_themes(); + match_slug(query, &themes) +} + +/// Resolve a character query within a single theme's roster. +pub fn match_character_in_theme(query: &str, theme: &ThemeFile) -> MatchResult { + let slugs: Vec = theme.characters.keys().cloned().collect(); + match_slug(query, &slugs) +} + +/// Global character search across every theme. Returns (theme_slug, +/// character_slug) pairs. +/// +/// Useful when the user supplies `--persona` without `--theme` or when +/// `--theme` failed to resolve. +pub fn match_character_globally(query: &str) -> MatchResult<(String, String)> { + // Build the qualified-slug list: "theme/character" — the fuzzy + // matcher operates on these and we split back on return. + let themes = persona::list_themes(); + let mut qualified: Vec = Vec::new(); + let mut lookup: Vec<(String, String)> = Vec::new(); + for theme_slug in &themes { + let Ok(theme) = persona::load_theme(theme_slug) else { + continue; + }; + for char_slug in theme.characters.keys() { + qualified.push(format!("{theme_slug}/{char_slug}")); + lookup.push((theme_slug.clone(), char_slug.clone())); + } + } + // The actual query fuzzy-matches against just the character-slug half — + // but we also let the theme half contribute (so "discworld/granny" + // queries work). nucleo handles the / separator fine. + let result = match_slug(query, &qualified); + // Map strings back to (theme, char) pairs. + let map = |q: String| -> (String, String) { + let pos = qualified.iter().position(|s| s == &q).unwrap_or(0); + lookup[pos].clone() + }; + match result { + MatchResult::Exact(q) => MatchResult::Exact(map(q)), + MatchResult::Prefix(q) => MatchResult::Prefix(map(q)), + MatchResult::FuzzyUnique(q) => MatchResult::FuzzyUnique(map(q)), + MatchResult::FuzzyAmbiguous { top, candidates } => MatchResult::FuzzyAmbiguous { + top: map(top), + candidates: candidates.into_iter().map(map).collect(), + }, + MatchResult::NotFound { candidates } => MatchResult::NotFound { + candidates: candidates.into_iter().map(map).collect(), + }, + } +} + +/// Two-phase resolve: theme first (to narrow), persona second. Returns +/// canonical slugs. `None`/`None` means the user supplied neither; the +/// caller should fall back to config defaults. +/// +/// Emits stderr warnings for ambiguous or fallback resolutions so the +/// user can see what we did (per session-032 design decision). +pub fn resolve_theme_and_persona( + theme_q: Option<&str>, + persona_q: Option<&str>, +) -> Result<(Option, Option)> { + // Phase 1 — resolve theme. + let theme_resolved: Option = match theme_q { + None | Some("") => None, + Some(q) => { + let m = match_theme(q); + emit_warning_if_fuzzy(q, "theme", &m); + match &m { + MatchResult::NotFound { .. } => None, + _ => m.picked(), + } + } + }; + + // Phase 2 — resolve persona. + let persona_q_nonempty = persona_q.filter(|q| !q.is_empty()); + let persona_resolved: Option<(String, String)> = match ( + theme_resolved.as_deref(), + persona_q_nonempty, + ) { + // Theme resolved, persona given — narrow to that theme. + (Some(t), Some(pq)) => { + let theme = persona::load_theme(t)?; + let m = match_character_in_theme(pq, &theme); + emit_warning_if_fuzzy(pq, "persona", &m); + match m { + MatchResult::NotFound { candidates } => { + return Err(ForestageError::CharacterNotFound { + character: format!( + "{pq} (no match; candidates: {})", + format_candidates(&candidates) + ), + theme: theme.theme.name, + }); + } + other => other.picked().map(|p| (t.to_string(), p)), + } + } + // No theme, persona given — global search + back-prop. + (None, Some(pq)) => { + let m = match_character_globally(pq); + let (theme_slug, char_slug) = match m { + MatchResult::NotFound { candidates } => { + return Err(ForestageError::CharacterNotFound { + character: format!( + "{pq} (no match; candidates: {})", + candidates + .iter() + .map(|(t, c)| format!("{c} ({t})")) + .collect::>() + .join(", ") + ), + theme: "(global)".into(), + }); + } + MatchResult::Exact(v) | MatchResult::Prefix(v) | MatchResult::FuzzyUnique(v) => v, + MatchResult::FuzzyAmbiguous { top, candidates } => { + let rendered: Vec = candidates + .iter() + .map(|(t, c)| format!("{c} ({t})")) + .collect(); + warn_stderr(&format!( + "persona '{pq}' is ambiguous — proceeding with top match. candidates: {}", + rendered.join(", ") + )); + top + } + }; + // If the user asked for a theme but we couldn't resolve it, + // note the back-propagation explicitly. + if let Some(orig) = theme_q { + if !orig.is_empty() { + warn_stderr(&format!( + "theme '{orig}' not found; resolved via persona '{pq}' → theme={theme_slug}" + )); + } + } + Some((theme_slug, char_slug)) + } + // Everything else: keep theme as-is, leave persona as None. + _ => None, + }; + + match persona_resolved { + Some((t, p)) => Ok((Some(t), Some(p))), + None => Ok((theme_resolved, None)), + } +} + +fn emit_warning_if_fuzzy(query: &str, kind: &str, result: &MatchResult) { + match result { + MatchResult::FuzzyUnique(v) => { + warn_stderr(&format!("{kind} '{query}' → {v} (fuzzy match)")); + } + MatchResult::FuzzyAmbiguous { top, candidates } => { + warn_stderr(&format!( + "{kind} '{query}' is ambiguous — proceeding with {top}. candidates: {}", + candidates.join(", ") + )); + } + MatchResult::NotFound { candidates } => { + warn_stderr(&format!( + "{kind} '{query}' not found. candidates: {}", + format_candidates(candidates) + )); + } + // Exact / Prefix — no warning needed. + _ => {} + } +} + +fn format_candidates(candidates: &[String]) -> String { + if candidates.is_empty() { + "(none)".to_string() + } else { + candidates.join(", ") + } +} + +fn warn_stderr(msg: &str) { + let _ = writeln!(std::io::stderr(), "forestage: {msg}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn themes() -> Vec { + vec![ + "discworld".into(), + "dune".into(), + "the-expanse".into(), + "breaking-bad".into(), + "alice-in-wonderland".into(), + ] + } + + #[test] + fn exact_match_wins() { + let r = match_slug("discworld", &themes()); + assert!(matches!(r, MatchResult::Exact(ref s) if s == "discworld")); + } + + #[test] + fn exact_match_case_insensitive() { + let r = match_slug("Dune", &themes()); + assert!(matches!(r, MatchResult::Exact(ref s) if s == "dune")); + } + + #[test] + fn unique_prefix_match() { + // "disc" only matches discworld. + let r = match_slug("disc", &themes()); + assert!(matches!(r, MatchResult::Prefix(ref s) if s == "discworld")); + } + + #[test] + fn empty_query_returns_not_found() { + let r = match_slug("", &themes()); + assert!(matches!(r, MatchResult::NotFound { .. })); + } + + #[test] + fn fuzzy_subsequence_matches() { + // A longer subsequence of "discworld" — scores above MIN_FUZZY_SCORE. + // (Very short queries like "dw" legitimately don't score high + // enough against a 5-candidate pool; that's the min-score cut + // doing its job.) + let r = match_slug("dcwrld", &themes()); + assert_eq!(r.picked().as_deref(), Some("discworld"), "got {r:?}"); + } + + #[test] + fn garbage_query_returns_not_found() { + let r = match_slug("zzzzzz_nonexistent_xxqxx", &themes()); + assert!(matches!(r, MatchResult::NotFound { .. })); + } + + #[test] + fn fuzzy_abbreviation_resolves_character_in_theme() { + // "grny" → granny-weatherwax within the discworld roster. + let theme = persona::load_theme("discworld").expect("discworld embedded"); + let r = match_character_in_theme("grny", &theme); + assert_eq!( + r.picked().as_deref(), + Some("granny-weatherwax"), + "got {r:?}" + ); + } + + #[test] + fn fuzzy_initials_resolve_in_theme() { + // "lhv" → lord-havelock-vetinari (initials of every word). + let theme = persona::load_theme("discworld").expect("discworld embedded"); + let r = match_character_in_theme("lhv", &theme); + assert_eq!( + r.picked().as_deref(), + Some("lord-havelock-vetinari"), + "got {r:?}" + ); + } + + #[test] + fn match_theme_on_embedded_data() { + // "dune" is embedded and should match exactly. + let r = match_theme("dune"); + assert!(matches!(r, MatchResult::Exact(ref s) if s == "dune")); + } + + #[test] + fn match_theme_prefix_on_embedded_data() { + // "disc" should be a unique prefix. + let r = match_theme("disc"); + assert!(matches!(r, MatchResult::Prefix(ref s) if s == "discworld")); + } + + #[test] + fn global_persona_search_finds_granny() { + let r = match_character_globally("granny-weatherwax"); + let got = r.picked().expect("granny-weatherwax should resolve"); + assert_eq!(got.0, "discworld"); + assert_eq!(got.1, "granny-weatherwax"); + } + + #[test] + fn resolve_theme_plus_persona_narrows_correctly() { + let (t, p) = resolve_theme_and_persona(Some("discworld"), Some("granny-weatherwax")) + .expect("should resolve"); + assert_eq!(t.as_deref(), Some("discworld")); + assert_eq!(p.as_deref(), Some("granny-weatherwax")); + } + + #[test] + fn resolve_persona_only_back_propagates_theme() { + let (t, p) = + resolve_theme_and_persona(None, Some("granny-weatherwax")).expect("should resolve"); + assert_eq!(t.as_deref(), Some("discworld")); + assert_eq!(p.as_deref(), Some("granny-weatherwax")); + } + + #[test] + fn resolve_neither_returns_none_none() { + let (t, p) = resolve_theme_and_persona(None, None).expect("no-op should succeed"); + assert_eq!(t, None); + assert_eq!(p, None); + } + + #[test] + fn resolve_theme_only_leaves_persona_none() { + let (t, p) = resolve_theme_and_persona(Some("dune"), None).expect("should resolve"); + assert_eq!(t.as_deref(), Some("dune")); + assert_eq!(p, None); + } +}