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///
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}