extro_core/lib.rs
1//! # Extro Core
2//!
3//! Pure Rust domain logic for the Extro browser extension framework.
4//!
5//! This crate provides the state machine, command dispatch, browser effects,
6//! and AI tool registry that form the deterministic "brain" of every Extro extension.
7//!
8//! **Key principle**: The model proposes; Rust decides.
9//!
10//! # Architecture
11//!
12//! ```text
13//! User Action → Content Script → Background → CoreState::dispatch() → BrowserEffects
14//! ```
15//!
16//! JavaScript never contains domain logic. It captures browser state, sends it here,
17//! and executes the returned effects.
18
19use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, VecDeque};
21use thiserror::Error;
22
23/// Identifies which browser extension surface originated a command.
24///
25/// Used by the core to apply surface-specific policies (e.g., content scripts
26/// cannot trigger certain effects, popups get toast notifications).
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub enum RuntimeSurface {
29 /// The background service worker (MV3).
30 Background,
31 /// A content script injected into a web page.
32 ContentScript,
33 /// The extension popup UI.
34 Popup,
35 /// The extension sidebar / side panel.
36 Sidebar,
37}
38
39/// A snapshot of the current browser state at the moment a command is issued.
40///
41/// Captured by JavaScript adapters and sent to the Rust core for processing.
42/// The core never reads browser state directly — it only receives these snapshots.
43///
44/// The `context` field carries arbitrary browser data (tabs, bookmarks, history,
45/// storage, DOM fragments, etc.) so that Rust can make decisions on any data
46/// without the architecture needing extension-specific plumbing.
47///
48/// # Example
49///
50/// ```json
51/// {
52/// "url": "https://example.com",
53/// "title": "Example",
54/// "selected_text": null,
55/// "context": {
56/// "tabs": [{"id": 1, "title": "Tab 1", "url": "..."}],
57/// "bookmarks": [{"id": "1", "title": "Saved", "url": "..."}]
58/// }
59/// }
60/// ```
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct BrowserSnapshot {
63 /// The URL of the active tab.
64 pub url: String,
65 /// The document title of the active tab.
66 pub title: String,
67 /// Text selected by the user, if any.
68 pub selected_text: Option<String>,
69 /// Arbitrary browser data gathered by JS before dispatch.
70 ///
71 /// JS adapters populate this with whatever the extension needs:
72 /// tabs, bookmarks, history, storage contents, DOM fragments, etc.
73 /// Rust parses the relevant fields in each action handler.
74 #[serde(default)]
75 pub context: serde_json::Value,
76}
77
78/// Actions that can be dispatched to the core state machine.
79///
80/// Add new variants here when extending the extension's capabilities.
81/// Each action maps to a handler in [`CoreState::dispatch`].
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub enum CoreAction {
84 /// Analyze text selected by the user on a web page.
85 AnalyzeSelection,
86 /// Summarize the current page content.
87 SummarizePage,
88 /// Synchronize state between surfaces (heartbeat / init).
89 SyncState,
90}
91
92/// A command sent from JavaScript to the Rust core.
93///
94/// This is the only entry point into the core's state machine.
95/// JavaScript adapters construct this from user actions and browser state.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CoreCommand {
98 /// Which surface sent this command.
99 pub surface: RuntimeSurface,
100 /// What action to perform.
101 pub action: CoreAction,
102 /// Current browser state snapshot.
103 pub snapshot: BrowserSnapshot,
104}
105
106/// Side effects that the Rust core requests the JavaScript runtime to execute.
107///
108/// The core never touches browser APIs directly. Instead, it returns a list of
109/// these effects, and the background service worker executes them in order.
110///
111/// # Adding a new effect
112///
113/// 1. Add a variant here
114/// 2. Handle dispatch in `CoreState::dispatch` to return it
115/// 3. Add a handler in `extension/src/background/index.js` `applyEffect()`
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub enum BrowserEffect {
118 /// Read the current DOM selection from the active tab.
119 ReadDomSelection,
120 /// Read clipboard contents via the Clipboard API.
121 ReadClipboard,
122 /// Persist a key-value pair in session storage.
123 PersistSession { key: String, value: String },
124 /// Show a toast notification in the popup UI.
125 ShowPopupToast { message: String },
126 /// Open the side panel to a specific route.
127 OpenSidePanel { route: String },
128 /// Inject a content script into the active tab.
129 InjectContentScript { file: String },
130}
131
132/// The result of processing a [`CoreCommand`].
133///
134/// Contains a human-readable message and a list of [`BrowserEffect`]s
135/// for the JavaScript runtime to execute.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct CoreResult {
138 /// Human-readable result message (displayed in UI or logged).
139 pub message: String,
140 /// Side effects to be executed by the JavaScript background worker.
141 pub effects: Vec<BrowserEffect>,
142}
143
144/// The central state machine for an Extro extension.
145///
146/// Owns all domain state and provides deterministic command dispatch.
147/// JavaScript never mutates this directly — it sends [`CoreCommand`]s
148/// and receives [`CoreResult`]s.
149///
150/// # Example
151///
152/// ```
153/// use extro_core::*;
154///
155/// let mut state = CoreState::new();
156/// let command = CoreCommand {
157/// surface: RuntimeSurface::Popup,
158/// action: CoreAction::SyncState,
159/// snapshot: BrowserSnapshot {
160/// url: "https://example.com".into(),
161/// title: "Example".into(),
162/// selected_text: None,
163/// context: serde_json::Value::Null,
164/// },
165/// };
166/// let result = state.dispatch(command);
167/// assert_eq!(result.message, "State synchronized");
168/// ```
169#[derive(Debug, Default)]
170pub struct CoreState {
171 log: VecDeque<String>,
172 session_counter: u64,
173}
174
175impl CoreState {
176 /// Create a new core state with zeroed counters and empty logs.
177 pub fn new() -> Self {
178 Self::default()
179 }
180
181 /// Dispatch a command and return the result with any side effects.
182 ///
183 /// This is the main entry point for all extension logic. Each command
184 /// increments the session counter and appends to the internal log.
185 pub fn dispatch(&mut self, command: CoreCommand) -> CoreResult {
186 self.session_counter += 1;
187 self.log.push_back(format!(
188 "#{} {:?} on {}",
189 self.session_counter, command.action, command.snapshot.url
190 ));
191
192 if self.log.len() > 100 {
193 self.log.pop_front();
194 }
195
196 match command.action {
197 CoreAction::AnalyzeSelection => CoreResult {
198 message: format!("Selection analysis prepared for {}", command.snapshot.title),
199 effects: vec![
200 BrowserEffect::ReadDomSelection,
201 BrowserEffect::ShowPopupToast {
202 message: "Selection sent to AI pipeline".into(),
203 },
204 ],
205 },
206 CoreAction::SummarizePage => CoreResult {
207 message: format!("Summary job queued for {}", command.snapshot.url),
208 effects: vec![
209 BrowserEffect::PersistSession {
210 key: "last_summary_url".into(),
211 value: command.snapshot.url,
212 },
213 BrowserEffect::OpenSidePanel {
214 route: "/jobs/latest".into(),
215 },
216 ],
217 },
218 CoreAction::SyncState => CoreResult {
219 message: "State synchronized".into(),
220 effects: vec![],
221 },
222 }
223 }
224
225 /// Return the telemetry log as a vector of strings.
226 ///
227 /// Each entry is a formatted record of a dispatched command.
228 pub fn telemetry(&self) -> Vec<String> {
229 self.log.iter().cloned().collect()
230 }
231
232 /// Return the full command history for agent introspection.
233 ///
234 /// Agents can use this to review what commands have been processed
235 /// and in what order, enabling replay and debugging.
236 pub fn history(&self) -> Vec<String> {
237 self.log.iter().cloned().collect()
238 }
239
240 /// Return the current session counter value.
241 pub fn session_count(&self) -> u64 {
242 self.session_counter
243 }
244}
245
246// ---------------------------------------------------------------------------
247// AI Tool Registry — "The model proposes; Rust decides."
248// ---------------------------------------------------------------------------
249
250/// A tool that AI models are allowed to invoke.
251///
252/// Each tool has a name, a human-readable description, and a JSON schema
253/// that defines its expected arguments. The [`ToolRegistry`] validates
254/// every AI tool call against these definitions before execution.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct ToolDefinition {
257 /// Unique name of the tool (e.g., `"summarize_page"`).
258 pub name: String,
259 /// Human-readable description of what the tool does.
260 pub description: String,
261 /// JSON schema for the tool's expected arguments.
262 pub parameters_schema: serde_json::Value,
263}
264
265/// A tool call proposed by an AI model.
266///
267/// The model selects a tool name and provides arguments. The Rust core
268/// validates this against the [`ToolRegistry`] before allowing execution.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct AIToolCall {
271 /// Name of the tool the model wants to invoke.
272 pub tool_name: String,
273 /// Arguments provided by the model.
274 pub arguments: serde_json::Value,
275}
276
277/// Registry of allowed AI tools with validation.
278///
279/// This is the policy enforcement layer. AI models can only invoke tools
280/// that are registered here, and their arguments must conform to the
281/// registered schema.
282///
283/// # Example
284///
285/// ```
286/// use extro_core::*;
287///
288/// let mut registry = ToolRegistry::new();
289/// registry.register(ToolDefinition {
290/// name: "summarize".into(),
291/// description: "Summarize page content".into(),
292/// parameters_schema: serde_json::json!({"type": "object"}),
293/// });
294///
295/// let call = AIToolCall {
296/// tool_name: "summarize".into(),
297/// arguments: serde_json::json!({}),
298/// };
299/// assert!(registry.validate(&call).is_ok());
300///
301/// let bad_call = AIToolCall {
302/// tool_name: "delete_everything".into(),
303/// arguments: serde_json::json!({}),
304/// };
305/// assert!(registry.validate(&bad_call).is_err());
306/// ```
307#[derive(Debug, Default)]
308pub struct ToolRegistry {
309 tools: HashMap<String, ToolDefinition>,
310}
311
312impl ToolRegistry {
313 /// Create an empty tool registry.
314 pub fn new() -> Self {
315 Self::default()
316 }
317
318 /// Register a tool that AI models are allowed to invoke.
319 pub fn register(&mut self, tool: ToolDefinition) {
320 self.tools.insert(tool.name.clone(), tool);
321 }
322
323 /// Validate an AI tool call against the registry.
324 ///
325 /// Returns `Ok(())` if the tool exists and is registered.
326 /// Returns `Err(CoreError::ToolNotRegistered)` if the tool is not allowed.
327 pub fn validate(&self, call: &AIToolCall) -> Result<&ToolDefinition, CoreError> {
328 self.tools
329 .get(&call.tool_name)
330 .ok_or_else(|| CoreError::ToolNotRegistered(call.tool_name.clone()))
331 }
332
333 /// List all registered tools (for agent discovery).
334 pub fn list_tools(&self) -> Vec<&ToolDefinition> {
335 self.tools.values().collect()
336 }
337
338 /// Check if a specific tool is registered.
339 pub fn has_tool(&self, name: &str) -> bool {
340 self.tools.contains_key(name)
341 }
342}
343
344/// Errors that can occur during core operations.
345#[derive(Debug, Error)]
346pub enum CoreError {
347 /// The command payload could not be deserialized.
348 #[error("invalid command payload")]
349 InvalidPayload,
350 /// An AI model tried to invoke a tool that is not registered.
351 #[error("tool '{0}' is not registered in the tool registry")]
352 ToolNotRegistered(String),
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_core_state_new() {
361 let state = CoreState::new();
362 assert_eq!(state.session_counter, 0);
363 assert!(state.log.is_empty());
364 }
365
366 #[test]
367 fn test_dispatch_analyze_selection() {
368 let mut state = CoreState::new();
369 let command = CoreCommand {
370 surface: RuntimeSurface::ContentScript,
371 action: CoreAction::AnalyzeSelection,
372 snapshot: BrowserSnapshot {
373 url: "https://example.com".to_string(),
374 title: "Test Page".to_string(),
375 selected_text: Some("Hello World".to_string()),
376 context: serde_json::Value::Null,
377 },
378 };
379
380 let result = state.dispatch(command);
381
382 assert!(result.message.contains("Selection analysis"));
383 assert_eq!(result.effects.len(), 2);
384 assert!(matches!(result.effects[0], BrowserEffect::ReadDomSelection));
385 assert!(matches!(
386 result.effects[1],
387 BrowserEffect::ShowPopupToast { .. }
388 ));
389 }
390
391 #[test]
392 fn test_dispatch_summarize_page() {
393 let mut state = CoreState::new();
394 let command = CoreCommand {
395 surface: RuntimeSurface::Popup,
396 action: CoreAction::SummarizePage,
397 snapshot: BrowserSnapshot {
398 url: "https://docs.example.com".to_string(),
399 title: "Docs".to_string(),
400 selected_text: None,
401 context: serde_json::Value::Null,
402 },
403 };
404
405 let result = state.dispatch(command);
406
407 assert!(result.message.contains("Summary job queued"));
408 assert_eq!(result.effects.len(), 2);
409 assert!(matches!(
410 result.effects[0],
411 BrowserEffect::PersistSession { .. }
412 ));
413 assert!(matches!(
414 result.effects[1],
415 BrowserEffect::OpenSidePanel { .. }
416 ));
417 }
418
419 #[test]
420 fn test_dispatch_sync_state() {
421 let mut state = CoreState::new();
422 let command = CoreCommand {
423 surface: RuntimeSurface::Background,
424 action: CoreAction::SyncState,
425 snapshot: BrowserSnapshot {
426 url: "https://example.com".to_string(),
427 title: "Test".to_string(),
428 selected_text: None,
429 context: serde_json::Value::Null,
430 },
431 };
432
433 let result = state.dispatch(command);
434
435 assert_eq!(result.message, "State synchronized");
436 assert!(result.effects.is_empty());
437 }
438
439 #[test]
440 fn test_session_counter_increments() {
441 let mut state = CoreState::new();
442
443 for i in 1..=5 {
444 let command = CoreCommand {
445 surface: RuntimeSurface::Popup,
446 action: CoreAction::SyncState,
447 snapshot: BrowserSnapshot {
448 url: "https://example.com".to_string(),
449 title: "Test".to_string(),
450 selected_text: None,
451 context: serde_json::Value::Null,
452 },
453 };
454 state.dispatch(command);
455 assert_eq!(state.session_counter, i);
456 }
457 }
458
459 #[test]
460 fn test_log_truncation() {
461 let mut state = CoreState::new();
462
463 // Dispatch 150 commands to test log truncation
464 for _ in 0..150 {
465 let command = CoreCommand {
466 surface: RuntimeSurface::Popup,
467 action: CoreAction::SyncState,
468 snapshot: BrowserSnapshot {
469 url: "https://example.com".to_string(),
470 title: "Test".to_string(),
471 selected_text: None,
472 context: serde_json::Value::Null,
473 },
474 };
475 state.dispatch(command);
476 }
477
478 // Log should be truncated to 100 entries
479 assert!(state.log.len() <= 100);
480 }
481
482 #[test]
483 fn test_telemetry() {
484 let mut state = CoreState::new();
485 let command = CoreCommand {
486 surface: RuntimeSurface::Popup,
487 action: CoreAction::SyncState,
488 snapshot: BrowserSnapshot {
489 url: "https://example.com".to_string(),
490 title: "Test".to_string(),
491 selected_text: None,
492 context: serde_json::Value::Null,
493 },
494 };
495 state.dispatch(command);
496
497 let telemetry = state.telemetry();
498 assert_eq!(telemetry.len(), 1);
499 assert!(telemetry[0].contains("SyncState"));
500 }
501
502 #[test]
503 fn test_browser_snapshot_serialization() {
504 let snapshot = BrowserSnapshot {
505 url: "https://example.com".to_string(),
506 title: "Test Page".to_string(),
507 selected_text: Some("Selected text".to_string()),
508 context: serde_json::json!({"test_key": "test_value"}),
509 };
510
511 let serialized = serde_json::to_string(&snapshot).unwrap();
512 let deserialized: BrowserSnapshot = serde_json::from_str(&serialized).unwrap();
513
514 assert_eq!(snapshot.url, deserialized.url);
515 assert_eq!(snapshot.title, deserialized.title);
516 assert_eq!(snapshot.selected_text, deserialized.selected_text);
517 }
518
519 #[test]
520 fn test_history() {
521 let mut state = CoreState::new();
522
523 for _ in 0..3 {
524 state.dispatch(CoreCommand {
525 surface: RuntimeSurface::Popup,
526 action: CoreAction::SyncState,
527 snapshot: BrowserSnapshot {
528 url: "https://example.com".into(),
529 title: "Test".into(),
530 selected_text: None,
531 context: serde_json::Value::Null,
532 },
533 });
534 }
535
536 assert_eq!(state.history().len(), 3);
537 assert_eq!(state.session_count(), 3);
538 }
539
540 #[test]
541 fn test_tool_registry_validate_registered() {
542 let mut registry = ToolRegistry::new();
543 registry.register(ToolDefinition {
544 name: "summarize".into(),
545 description: "Summarize page content".into(),
546 parameters_schema: serde_json::json!({"type": "object"}),
547 });
548
549 let call = AIToolCall {
550 tool_name: "summarize".into(),
551 arguments: serde_json::json!({}),
552 };
553
554 assert!(registry.validate(&call).is_ok());
555 assert!(registry.has_tool("summarize"));
556 }
557
558 #[test]
559 fn test_tool_registry_reject_unregistered() {
560 let registry = ToolRegistry::new();
561
562 let call = AIToolCall {
563 tool_name: "delete_everything".into(),
564 arguments: serde_json::json!({}),
565 };
566
567 let err = registry.validate(&call).unwrap_err();
568 assert!(matches!(err, CoreError::ToolNotRegistered(_)));
569 assert!(err.to_string().contains("delete_everything"));
570 }
571
572 #[test]
573 fn test_tool_registry_list_tools() {
574 let mut registry = ToolRegistry::new();
575 registry.register(ToolDefinition {
576 name: "tool_a".into(),
577 description: "Tool A".into(),
578 parameters_schema: serde_json::json!({}),
579 });
580 registry.register(ToolDefinition {
581 name: "tool_b".into(),
582 description: "Tool B".into(),
583 parameters_schema: serde_json::json!({}),
584 });
585
586 assert_eq!(registry.list_tools().len(), 2);
587 }
588}