Skip to main content

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