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 scored.sort_by(|a, b| b.score.total_cmp(&a.score));
245 scored
246 }
247}
248
249/// Returns all commands registered via `#[inspectable]` auto-discovery.
250///
251/// Collects every `CommandInfoFactory` submitted by the `#[inspectable]` macro
252/// and calls each factory to produce `CommandInfo` values.
253#[must_use]
254pub fn auto_discovered_commands() -> Vec<CommandInfo> {
255 inventory::iter::<CommandInfoFactory>
256 .into_iter()
257 .map(|factory| (factory.0)())
258 .collect()
259}
260
261impl CommandRegistry {
262 /// Creates a registry pre-populated with all `#[inspectable]` commands.
263 ///
264 /// Uses `inventory` to collect every `CommandInfo` that was submitted at
265 /// link time by the `#[inspectable]` macro. This replaces manual
266 /// `register_commands!` or `.commands(&[...])` calls.
267 ///
268 /// ```
269 /// use victauri_core::CommandRegistry;
270 ///
271 /// let registry = CommandRegistry::from_auto_discovery();
272 /// // Contains all #[inspectable] commands from the binary
273 /// ```
274 #[must_use]
275 pub fn from_auto_discovery() -> Self {
276 let registry = Self::new();
277 for info in auto_discovered_commands() {
278 registry.register(info);
279 }
280 registry
281 }
282}
283
284impl Default for CommandRegistry {
285 fn default() -> Self {
286 Self::new()
287 }
288}
289
290/// A command paired with its relevance score from natural-language resolution.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct ScoredCommand {
293 /// The matched command metadata.
294 pub command: CommandInfo,
295 /// Relevance score (higher is better); 0 means no match.
296 pub score: f64,
297}
298
299impl fmt::Display for ScoredCommand {
300 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301 write!(f, "{} (score: {:.2})", self.command.name, self.score)
302 }
303}
304
305const SCORE_EXACT_NAME: f64 = 10.0;
306const SCORE_NAME_SUBSTRING: f64 = 3.0;
307const SCORE_NAME_WORD: f64 = 2.0;
308const SCORE_DESCRIPTION: f64 = 1.5;
309const SCORE_INTENT: f64 = 2.5;
310/// Bonus when the query exactly matches a command's natural-language intent.
311/// Mirrors [`SCORE_EXACT_NAME`]: an exact intent hit is the entire purpose of the
312/// `intent` field, so it must dominate incidental name-substring matches in
313/// unrelated commands. Without it, `resolve("increase counter")` ranks
314/// `get_counter` (whose *name* contains "counter") above `increment` (whose
315/// *intent* is literally "increase counter"). Kept below `SCORE_EXACT_NAME` so a
316/// literal command-name match still edges out a natural-language intent match.
317const SCORE_EXACT_INTENT: f64 = 8.0;
318const SCORE_CATEGORY: f64 = 1.0;
319const SCORE_EXAMPLE_FULL: f64 = 4.0;
320const SCORE_EXAMPLE_WORD: f64 = 0.5;
321
322/// Scores a command against a query. Per-word contributions (substring, word,
323/// description, intent, category, example-word matches) are normalized by query
324/// length so scores remain comparable across queries of different word counts.
325/// Whole-query bonuses (exact name match, full example match) are not normalized.
326fn score_command(cmd: &CommandInfo, query_lower: &str, query_words: &[&str]) -> f64 {
327 let mut score = 0.0;
328 let mut exact_bonus = 0.0;
329 let name_lower = cmd.name.to_lowercase();
330 let name_words: Vec<&str> = name_lower.split('_').collect();
331
332 if name_lower == query_lower.replace(' ', "_") {
333 exact_bonus += SCORE_EXACT_NAME;
334 }
335
336 for word in query_words {
337 if name_lower.contains(word) {
338 score += SCORE_NAME_SUBSTRING;
339 }
340 if name_words.contains(word) {
341 score += SCORE_NAME_WORD;
342 }
343 }
344
345 if let Some(desc) = &cmd.description {
346 let desc_lower = desc.to_lowercase();
347 for word in query_words {
348 if desc_lower.contains(word) {
349 score += SCORE_DESCRIPTION;
350 }
351 }
352 }
353
354 if let Some(intent) = &cmd.intent {
355 let intent_lower = intent.to_lowercase();
356 if intent_lower.as_str() == query_lower {
357 exact_bonus += SCORE_EXACT_INTENT;
358 }
359 for word in query_words {
360 if intent_lower.contains(word) {
361 score += SCORE_INTENT;
362 }
363 }
364 }
365
366 if let Some(category) = &cmd.category {
367 let cat_lower = category.to_lowercase();
368 for word in query_words {
369 if cat_lower.contains(word) {
370 score += SCORE_CATEGORY;
371 }
372 }
373 }
374
375 for example in &cmd.examples {
376 let ex_lower = example.to_lowercase();
377 if ex_lower.contains(query_lower) {
378 exact_bonus += SCORE_EXAMPLE_FULL;
379 break;
380 }
381 for word in query_words {
382 if ex_lower.contains(word) {
383 score += SCORE_EXAMPLE_WORD;
384 }
385 }
386 }
387
388 // Normalize per-word contributions so scores are comparable across queries of different lengths.
389 let word_count = query_words.len() as f64;
390 let per_word_score = score / word_count;
391 exact_bonus + per_word_score
392}