1use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, VecDeque};
21use thiserror::Error;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
28pub enum RuntimeSurface {
29 Background,
31 ContentScript,
33 Popup,
35 Sidebar,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct BrowserSnapshot {
45 pub url: String,
47 pub title: String,
49 pub selected_text: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
58pub enum CoreAction {
59 AnalyzeSelection,
61 SummarizePage,
63 SyncState,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CoreCommand {
73 pub surface: RuntimeSurface,
75 pub action: CoreAction,
77 pub snapshot: BrowserSnapshot,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
92pub enum BrowserEffect {
93 ReadDomSelection,
95 ReadClipboard,
97 PersistSession { key: String, value: String },
99 ShowPopupToast { message: String },
101 OpenSidePanel { route: String },
103 InjectContentScript { file: String },
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct CoreResult {
113 pub message: String,
115 pub effects: Vec<BrowserEffect>,
117}
118
119#[derive(Debug, Default)]
144pub struct CoreState {
145 log: VecDeque<String>,
146 session_counter: u64,
147}
148
149impl CoreState {
150 pub fn new() -> Self {
152 Self::default()
153 }
154
155 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 pub fn telemetry(&self) -> Vec<String> {
203 self.log.iter().cloned().collect()
204 }
205
206 pub fn history(&self) -> Vec<String> {
211 self.log.iter().cloned().collect()
212 }
213
214 pub fn session_count(&self) -> u64 {
216 self.session_counter
217 }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ToolDefinition {
231 pub name: String,
233 pub description: String,
235 pub parameters_schema: serde_json::Value,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct AIToolCall {
245 pub tool_name: String,
247 pub arguments: serde_json::Value,
249}
250
251#[derive(Debug, Default)]
282pub struct ToolRegistry {
283 tools: HashMap<String, ToolDefinition>,
284}
285
286impl ToolRegistry {
287 pub fn new() -> Self {
289 Self::default()
290 }
291
292 pub fn register(&mut self, tool: ToolDefinition) {
294 self.tools.insert(tool.name.clone(), tool);
295 }
296
297 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 pub fn list_tools(&self) -> Vec<&ToolDefinition> {
309 self.tools.values().collect()
310 }
311
312 pub fn has_tool(&self, name: &str) -> bool {
314 self.tools.contains_key(name)
315 }
316}
317
318#[derive(Debug, Error)]
320pub enum CoreError {
321 #[error("invalid command payload")]
323 InvalidPayload,
324 #[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 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 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}