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}