use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RuntimeSurface {
Background,
ContentScript,
Popup,
Sidebar,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserSnapshot {
pub url: String,
pub title: String,
pub selected_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CoreAction {
AnalyzeSelection,
SummarizePage,
SyncState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoreCommand {
pub surface: RuntimeSurface,
pub action: CoreAction,
pub snapshot: BrowserSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BrowserEffect {
ReadDomSelection,
ReadClipboard,
PersistSession { key: String, value: String },
ShowPopupToast { message: String },
OpenSidePanel { route: String },
InjectContentScript { file: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoreResult {
pub message: String,
pub effects: Vec<BrowserEffect>,
}
#[derive(Debug, Default)]
pub struct CoreState {
log: VecDeque<String>,
session_counter: u64,
}
impl CoreState {
pub fn new() -> Self {
Self::default()
}
pub fn dispatch(&mut self, command: CoreCommand) -> CoreResult {
self.session_counter += 1;
self.log.push_back(format!(
"#{} {:?} on {}",
self.session_counter, command.action, command.snapshot.url
));
if self.log.len() > 100 {
self.log.pop_front();
}
match command.action {
CoreAction::AnalyzeSelection => CoreResult {
message: format!("Selection analysis prepared for {}", command.snapshot.title),
effects: vec![
BrowserEffect::ReadDomSelection,
BrowserEffect::ShowPopupToast {
message: "Selection sent to AI pipeline".into(),
},
],
},
CoreAction::SummarizePage => CoreResult {
message: format!("Summary job queued for {}", command.snapshot.url),
effects: vec![
BrowserEffect::PersistSession {
key: "last_summary_url".into(),
value: command.snapshot.url,
},
BrowserEffect::OpenSidePanel {
route: "/jobs/latest".into(),
},
],
},
CoreAction::SyncState => CoreResult {
message: "State synchronized".into(),
effects: vec![],
},
}
}
pub fn telemetry(&self) -> Vec<String> {
self.log.iter().cloned().collect()
}
pub fn history(&self) -> Vec<String> {
self.log.iter().cloned().collect()
}
pub fn session_count(&self) -> u64 {
self.session_counter
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters_schema: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AIToolCall {
pub tool_name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Default)]
pub struct ToolRegistry {
tools: HashMap<String, ToolDefinition>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, tool: ToolDefinition) {
self.tools.insert(tool.name.clone(), tool);
}
pub fn validate(&self, call: &AIToolCall) -> Result<&ToolDefinition, CoreError> {
self.tools
.get(&call.tool_name)
.ok_or_else(|| CoreError::ToolNotRegistered(call.tool_name.clone()))
}
pub fn list_tools(&self) -> Vec<&ToolDefinition> {
self.tools.values().collect()
}
pub fn has_tool(&self, name: &str) -> bool {
self.tools.contains_key(name)
}
}
#[derive(Debug, Error)]
pub enum CoreError {
#[error("invalid command payload")]
InvalidPayload,
#[error("tool '{0}' is not registered in the tool registry")]
ToolNotRegistered(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_core_state_new() {
let state = CoreState::new();
assert_eq!(state.session_counter, 0);
assert!(state.log.is_empty());
}
#[test]
fn test_dispatch_analyze_selection() {
let mut state = CoreState::new();
let command = CoreCommand {
surface: RuntimeSurface::ContentScript,
action: CoreAction::AnalyzeSelection,
snapshot: BrowserSnapshot {
url: "https://example.com".to_string(),
title: "Test Page".to_string(),
selected_text: Some("Hello World".to_string()),
},
};
let result = state.dispatch(command);
assert!(result.message.contains("Selection analysis"));
assert_eq!(result.effects.len(), 2);
assert!(matches!(result.effects[0], BrowserEffect::ReadDomSelection));
assert!(matches!(
result.effects[1],
BrowserEffect::ShowPopupToast { .. }
));
}
#[test]
fn test_dispatch_summarize_page() {
let mut state = CoreState::new();
let command = CoreCommand {
surface: RuntimeSurface::Popup,
action: CoreAction::SummarizePage,
snapshot: BrowserSnapshot {
url: "https://docs.example.com".to_string(),
title: "Docs".to_string(),
selected_text: None,
},
};
let result = state.dispatch(command);
assert!(result.message.contains("Summary job queued"));
assert_eq!(result.effects.len(), 2);
assert!(matches!(
result.effects[0],
BrowserEffect::PersistSession { .. }
));
assert!(matches!(
result.effects[1],
BrowserEffect::OpenSidePanel { .. }
));
}
#[test]
fn test_dispatch_sync_state() {
let mut state = CoreState::new();
let command = CoreCommand {
surface: RuntimeSurface::Background,
action: CoreAction::SyncState,
snapshot: BrowserSnapshot {
url: "https://example.com".to_string(),
title: "Test".to_string(),
selected_text: None,
},
};
let result = state.dispatch(command);
assert_eq!(result.message, "State synchronized");
assert!(result.effects.is_empty());
}
#[test]
fn test_session_counter_increments() {
let mut state = CoreState::new();
for i in 1..=5 {
let command = CoreCommand {
surface: RuntimeSurface::Popup,
action: CoreAction::SyncState,
snapshot: BrowserSnapshot {
url: "https://example.com".to_string(),
title: "Test".to_string(),
selected_text: None,
},
};
state.dispatch(command);
assert_eq!(state.session_counter, i);
}
}
#[test]
fn test_log_truncation() {
let mut state = CoreState::new();
for _ in 0..150 {
let command = CoreCommand {
surface: RuntimeSurface::Popup,
action: CoreAction::SyncState,
snapshot: BrowserSnapshot {
url: "https://example.com".to_string(),
title: "Test".to_string(),
selected_text: None,
},
};
state.dispatch(command);
}
assert!(state.log.len() <= 100);
}
#[test]
fn test_telemetry() {
let mut state = CoreState::new();
let command = CoreCommand {
surface: RuntimeSurface::Popup,
action: CoreAction::SyncState,
snapshot: BrowserSnapshot {
url: "https://example.com".to_string(),
title: "Test".to_string(),
selected_text: None,
},
};
state.dispatch(command);
let telemetry = state.telemetry();
assert_eq!(telemetry.len(), 1);
assert!(telemetry[0].contains("SyncState"));
}
#[test]
fn test_browser_snapshot_serialization() {
let snapshot = BrowserSnapshot {
url: "https://example.com".to_string(),
title: "Test Page".to_string(),
selected_text: Some("Selected text".to_string()),
};
let serialized = serde_json::to_string(&snapshot).unwrap();
let deserialized: BrowserSnapshot = serde_json::from_str(&serialized).unwrap();
assert_eq!(snapshot.url, deserialized.url);
assert_eq!(snapshot.title, deserialized.title);
assert_eq!(snapshot.selected_text, deserialized.selected_text);
}
#[test]
fn test_history() {
let mut state = CoreState::new();
for _ in 0..3 {
state.dispatch(CoreCommand {
surface: RuntimeSurface::Popup,
action: CoreAction::SyncState,
snapshot: BrowserSnapshot {
url: "https://example.com".into(),
title: "Test".into(),
selected_text: None,
},
});
}
assert_eq!(state.history().len(), 3);
assert_eq!(state.session_count(), 3);
}
#[test]
fn test_tool_registry_validate_registered() {
let mut registry = ToolRegistry::new();
registry.register(ToolDefinition {
name: "summarize".into(),
description: "Summarize page content".into(),
parameters_schema: serde_json::json!({"type": "object"}),
});
let call = AIToolCall {
tool_name: "summarize".into(),
arguments: serde_json::json!({}),
};
assert!(registry.validate(&call).is_ok());
assert!(registry.has_tool("summarize"));
}
#[test]
fn test_tool_registry_reject_unregistered() {
let registry = ToolRegistry::new();
let call = AIToolCall {
tool_name: "delete_everything".into(),
arguments: serde_json::json!({}),
};
let err = registry.validate(&call).unwrap_err();
assert!(matches!(err, CoreError::ToolNotRegistered(_)));
assert!(err.to_string().contains("delete_everything"));
}
#[test]
fn test_tool_registry_list_tools() {
let mut registry = ToolRegistry::new();
registry.register(ToolDefinition {
name: "tool_a".into(),
description: "Tool A".into(),
parameters_schema: serde_json::json!({}),
});
registry.register(ToolDefinition {
name: "tool_b".into(),
description: "Tool B".into(),
parameters_schema: serde_json::json!({}),
});
assert_eq!(registry.list_tools().len(), 2);
}
}