Skip to main content

victauri_core/
registry.rs

1//! Thread-safe command registry with substring search and
2//! natural-language-to-command resolution.
3
4use std::collections::BTreeMap;
5use std::fmt;
6use std::sync::{Arc, RwLock};
7
8use serde::{Deserialize, Serialize};
9
10/// Metadata for a registered Tauri command, including intent and schema information.
11#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
12pub struct CommandInfo {
13    /// Fully qualified command name (e.g. "`get_settings`").
14    pub name: String,
15    /// Plugin namespace, if the command belongs to a Tauri plugin.
16    pub plugin: Option<String>,
17    /// Human-readable description of what the command does.
18    pub description: Option<String>,
19    /// Ordered list of arguments the command accepts.
20    pub args: Vec<CommandArg>,
21    /// Rust return type as a string (e.g. "Result<Settings, Error>").
22    pub return_type: Option<String>,
23    /// Whether the command handler is async.
24    pub is_async: bool,
25    /// Natural-language intent phrase for NL-to-command resolution.
26    pub intent: Option<String>,
27    /// Grouping category (e.g. "settings", "counter").
28    pub category: Option<String>,
29    /// Example natural-language queries that should resolve to this command.
30    pub examples: Vec<String>,
31}
32
33/// Schema for a single argument of a registered command.
34#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
35pub struct CommandArg {
36    /// Argument name as declared in the Rust function signature.
37    pub name: String,
38    /// Rust type name (e.g. "String", "`Option<u32>`").
39    pub type_name: String,
40    /// Whether the argument must be provided (not `Option`).
41    pub required: bool,
42    /// Optional JSON Schema for the argument's expected shape.
43    pub schema: Option<serde_json::Value>,
44}
45
46/// Factory function submitted by `#[inspectable]` for auto-discovery.
47///
48/// Wraps a `fn() -> CommandInfo` so it can be registered via `inventory`
49/// (function pointers are const-constructible, unlike `CommandInfo` with its `String` fields).
50#[doc(hidden)]
51pub struct CommandInfoFactory(pub fn() -> CommandInfo);
52
53inventory::collect!(CommandInfoFactory);
54
55impl CommandInfo {
56    /// Creates a new command with the given name and all optional fields set to `None`/empty.
57    ///
58    /// # Examples
59    ///
60    /// ```
61    /// use victauri_core::CommandInfo;
62    ///
63    /// let cmd = CommandInfo::new("greet");
64    /// assert_eq!(cmd.name, "greet");
65    /// assert!(cmd.description.is_none());
66    /// ```
67    #[must_use]
68    pub fn new(name: impl Into<String>) -> Self {
69        Self {
70            name: name.into(),
71            plugin: None,
72            description: None,
73            args: Vec::new(),
74            return_type: None,
75            is_async: false,
76            intent: None,
77            category: None,
78            examples: Vec::new(),
79        }
80    }
81
82    /// Sets the description.
83    #[must_use]
84    pub fn with_description(mut self, description: impl Into<String>) -> Self {
85        self.description = Some(description.into());
86        self
87    }
88
89    /// Sets the intent phrase for natural-language resolution.
90    #[must_use]
91    pub fn with_intent(mut self, intent: impl Into<String>) -> Self {
92        self.intent = Some(intent.into());
93        self
94    }
95
96    /// Sets the category.
97    #[must_use]
98    pub fn with_category(mut self, category: impl Into<String>) -> Self {
99        self.category = Some(category.into());
100        self
101    }
102}
103
104/// Thread-safe registry of known Tauri commands, indexed by name.
105#[derive(Debug, Clone)]
106pub struct CommandRegistry {
107    commands: Arc<RwLock<BTreeMap<String, CommandInfo>>>,
108}
109
110impl CommandRegistry {
111    /// Creates an empty command registry.
112    ///
113    /// ```
114    /// use victauri_core::CommandRegistry;
115    ///
116    /// let registry = CommandRegistry::new();
117    /// assert_eq!(registry.count(), 0);
118    /// assert!(registry.list().is_empty());
119    /// ```
120    #[must_use]
121    pub fn new() -> Self {
122        Self {
123            commands: Arc::new(RwLock::new(BTreeMap::new())),
124        }
125    }
126
127    /// Registers a command, replacing any existing entry with the same name.
128    ///
129    /// ```
130    /// use victauri_core::{CommandRegistry, CommandInfo};
131    ///
132    /// let registry = CommandRegistry::new();
133    /// registry.register(CommandInfo::new("greet").with_description("Say hello"));
134    /// assert_eq!(registry.count(), 1);
135    /// assert!(registry.get("greet").is_some());
136    /// ```
137    pub fn register(&self, info: CommandInfo) {
138        crate::acquire_write(&self.commands, "CommandRegistry").insert(info.name.clone(), info);
139    }
140
141    /// Looks up a command by exact name.
142    #[must_use]
143    pub fn get(&self, name: &str) -> Option<CommandInfo> {
144        crate::acquire_read(&self.commands, "CommandRegistry")
145            .get(name)
146            .cloned()
147    }
148
149    /// Returns all registered commands in alphabetical order.
150    #[must_use]
151    pub fn list(&self) -> Vec<CommandInfo> {
152        crate::acquire_read(&self.commands, "CommandRegistry")
153            .values()
154            .cloned()
155            .collect()
156    }
157
158    /// Returns the number of registered commands.
159    #[must_use]
160    pub fn count(&self) -> usize {
161        crate::acquire_read(&self.commands, "CommandRegistry").len()
162    }
163
164    /// Searches commands by substring match on name or description (case-insensitive).
165    ///
166    /// # Examples
167    ///
168    /// ```
169    /// use victauri_core::{CommandRegistry, CommandInfo};
170    ///
171    /// let registry = CommandRegistry::new();
172    /// registry.register(
173    ///     CommandInfo::new("get_settings").with_description("Retrieve app settings"),
174    /// );
175    /// let results = registry.search("settings");
176    /// assert_eq!(results.len(), 1);
177    /// assert_eq!(results[0].name, "get_settings");
178    /// ```
179    #[must_use]
180    pub fn search(&self, query: &str) -> Vec<CommandInfo> {
181        let query_lower = query.to_lowercase();
182        crate::acquire_read(&self.commands, "CommandRegistry")
183            .values()
184            .filter(|cmd| {
185                cmd.name.to_lowercase().contains(&query_lower)
186                    || cmd
187                        .description
188                        .as_ref()
189                        .is_some_and(|d| d.to_lowercase().contains(&query_lower))
190            })
191            .cloned()
192            .collect()
193    }
194
195    /// Resolves a natural-language query to commands ranked by relevance score.
196    ///
197    /// # Examples
198    ///
199    /// ```
200    /// use victauri_core::{CommandRegistry, CommandInfo};
201    ///
202    /// let registry = CommandRegistry::new();
203    /// registry.register(
204    ///     CommandInfo::new("get_settings")
205    ///         .with_description("Retrieve app settings")
206    ///         .with_intent("fetch configuration")
207    ///         .with_category("settings"),
208    /// );
209    /// let results = registry.resolve("get settings");
210    /// assert!(!results.is_empty());
211    /// assert!(results[0].score > 0.0);
212    /// ```
213    #[must_use]
214    pub fn resolve(&self, query: &str) -> Vec<ScoredCommand> {
215        // Scoring is O(commands × query_words × field_len), so an unbounded query
216        // is a CPU/allocation DoS. Cap the query length (audit #20); a few hundred
217        // chars is far more than any real natural-language command query.
218        const MAX_QUERY_LEN: usize = 512;
219        let query_lower: String = query
220            .chars()
221            .take(MAX_QUERY_LEN)
222            .collect::<String>()
223            .to_lowercase();
224        let query_words: Vec<&str> = query_lower.split_whitespace().collect();
225        if query_words.is_empty() {
226            return Vec::new();
227        }
228
229        let mut scored: Vec<ScoredCommand> = crate::acquire_read(&self.commands, "CommandRegistry")
230            .values()
231            .filter_map(|cmd| {
232                let score = score_command(cmd, &query_lower, &query_words);
233                if score > 0.0 {
234                    Some(ScoredCommand {
235                        command: cmd.clone(),
236                        score,
237                    })
238                } else {
239                    None
240                }
241            })
242            .collect();
243
244        // Primary: descending score. Secondary: a DETERMINISTIC tiebreak by command name
245        // so equal-scoring commands never come back in arbitrary (HashMap iteration) order —
246        // the "degenerate N-way tie with no tiebreak" of VIC-3. Combined with the name-coverage
247        // term in `score_command`, ranking now degrades gracefully instead of opaquely.
248        scored.sort_by(|a, b| {
249            b.score
250                .total_cmp(&a.score)
251                .then_with(|| a.command.name.cmp(&b.command.name))
252        });
253        scored
254    }
255}
256
257/// Returns all commands registered via `#[inspectable]` auto-discovery.
258///
259/// Collects every `CommandInfoFactory` submitted by the `#[inspectable]` macro
260/// and calls each factory to produce `CommandInfo` values.
261#[must_use]
262pub fn auto_discovered_commands() -> Vec<CommandInfo> {
263    inventory::iter::<CommandInfoFactory>
264        .into_iter()
265        .map(|factory| (factory.0)())
266        .collect()
267}
268
269impl CommandRegistry {
270    /// Creates a registry pre-populated with all `#[inspectable]` commands.
271    ///
272    /// Uses `inventory` to collect every `CommandInfo` that was submitted at
273    /// link time by the `#[inspectable]` macro. This replaces manual
274    /// `register_commands!` or `.commands(&[...])` calls.
275    ///
276    /// ```
277    /// use victauri_core::CommandRegistry;
278    ///
279    /// let registry = CommandRegistry::from_auto_discovery();
280    /// // Contains all #[inspectable] commands from the binary
281    /// ```
282    #[must_use]
283    pub fn from_auto_discovery() -> Self {
284        let registry = Self::new();
285        for info in auto_discovered_commands() {
286            registry.register(info);
287        }
288        registry
289    }
290}
291
292impl Default for CommandRegistry {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298/// A command paired with its relevance score from natural-language resolution.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ScoredCommand {
301    /// The matched command metadata.
302    pub command: CommandInfo,
303    /// Relevance score (higher is better); 0 means no match.
304    pub score: f64,
305}
306
307impl fmt::Display for ScoredCommand {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        write!(f, "{} (score: {:.2})", self.command.name, self.score)
310    }
311}
312
313const SCORE_EXACT_NAME: f64 = 10.0;
314const SCORE_NAME_SUBSTRING: f64 = 3.0;
315const SCORE_NAME_WORD: f64 = 2.0;
316const SCORE_DESCRIPTION: f64 = 1.5;
317const SCORE_INTENT: f64 = 2.5;
318/// Bonus when the query exactly matches a command's natural-language intent.
319/// Mirrors [`SCORE_EXACT_NAME`]: an exact intent hit is the entire purpose of the
320/// `intent` field, so it must dominate incidental name-substring matches in
321/// unrelated commands. Without it, `resolve("increase counter")` ranks
322/// `get_counter` (whose *name* contains "counter") above `increment` (whose
323/// *intent* is literally "increase counter"). Kept below `SCORE_EXACT_NAME` so a
324/// literal command-name match still edges out a natural-language intent match.
325const SCORE_EXACT_INTENT: f64 = 8.0;
326const SCORE_CATEGORY: f64 = 1.0;
327const SCORE_EXAMPLE_FULL: f64 = 4.0;
328const SCORE_EXAMPLE_WORD: f64 = 0.5;
329/// Whole-command specificity bonus: the fraction of the command's NAME tokens covered by
330/// the query, scaled by this weight. It rewards a more complete name match (a query word
331/// hitting the short `settings` outranks the same word buried in `get_app_settings_v2`),
332/// so when several commands match a single query word equally — the VIC-3 N-way tie — the
333/// more specific one ranks higher. Small by design: it breaks near-ties without ever
334/// overriding intent/exact-name/description signal.
335const SCORE_NAME_COVERAGE: f64 = 1.0;
336
337/// Scores a command against a query. Per-word contributions (substring, word,
338/// description, intent, category, example-word matches) are normalized by query
339/// length so scores remain comparable across queries of different word counts.
340/// Whole-query bonuses (exact name match, full example match) are not normalized.
341fn score_command(cmd: &CommandInfo, query_lower: &str, query_words: &[&str]) -> f64 {
342    let mut score = 0.0;
343    let mut exact_bonus = 0.0;
344    let name_lower = cmd.name.to_lowercase();
345    let name_words: Vec<&str> = name_lower.split('_').collect();
346
347    if name_lower == query_lower.replace(' ', "_") {
348        exact_bonus += SCORE_EXACT_NAME;
349    }
350
351    for word in query_words {
352        if name_lower.contains(word) {
353            score += SCORE_NAME_SUBSTRING;
354        }
355        if name_words.contains(word) {
356            score += SCORE_NAME_WORD;
357        }
358    }
359
360    if let Some(desc) = &cmd.description {
361        let desc_lower = desc.to_lowercase();
362        for word in query_words {
363            if desc_lower.contains(word) {
364                score += SCORE_DESCRIPTION;
365            }
366        }
367    }
368
369    if let Some(intent) = &cmd.intent {
370        let intent_lower = intent.to_lowercase();
371        if intent_lower.as_str() == query_lower {
372            exact_bonus += SCORE_EXACT_INTENT;
373        }
374        for word in query_words {
375            if intent_lower.contains(word) {
376                score += SCORE_INTENT;
377            }
378        }
379    }
380
381    if let Some(category) = &cmd.category {
382        let cat_lower = category.to_lowercase();
383        for word in query_words {
384            if cat_lower.contains(word) {
385                score += SCORE_CATEGORY;
386            }
387        }
388    }
389
390    for example in &cmd.examples {
391        let ex_lower = example.to_lowercase();
392        if ex_lower.contains(query_lower) {
393            exact_bonus += SCORE_EXAMPLE_FULL;
394            break;
395        }
396        for word in query_words {
397            if ex_lower.contains(word) {
398                score += SCORE_EXAMPLE_WORD;
399            }
400        }
401    }
402
403    // Name-coverage specificity (graceful tiebreak — see SCORE_NAME_COVERAGE). Fraction of
404    // the command's name tokens the query covers; a whole-command bonus, not per-word.
405    let matched_name_words = name_words
406        .iter()
407        .filter(|w| !w.is_empty() && query_words.contains(w))
408        .count();
409    let name_coverage = if name_words.is_empty() {
410        0.0
411    } else {
412        matched_name_words as f64 / name_words.len() as f64
413    };
414
415    // Normalize per-word contributions so scores are comparable across queries of different lengths.
416    let word_count = query_words.len() as f64;
417    let per_word_score = score / word_count;
418    exact_bonus + per_word_score + SCORE_NAME_COVERAGE * name_coverage
419}