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