//! Query feed and reader view commands. use crate::commands::error::ApiError; use crate::state::AppState; use bb_db::{CreateQueryFeed, QueryCondition, QueryFeedId}; use bb_feed::{FeedFilter, FeedGenerator}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::State; use tracing::instrument; // ── Query feed types ────────────────────────────────────────────── /// Response for a single query feed, including a live match count. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct QueryFeedResponse { pub id: String, pub name: String, pub rules: Vec, pub match_count: i64, } /// Input for creating or updating a query feed. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QueryFeedInput { pub name: String, pub rules: Vec, } // ── Validation ──────────────────────────────────────────────────── /// Valid (field, operator) combinations for query conditions. fn validate_condition(c: &QueryCondition) -> Result<(), String> { match (c.field.as_str(), c.operator.as_str()) { ("title", "contains" | "not_contains" | "equals" | "matches_regex") => Ok(()), ("author", "contains" | "equals" | "matches_regex") => Ok(()), ("body", "contains" | "not_contains" | "matches_regex") => Ok(()), ("source", "equals") => Ok(()), ("tag", "equals") => Ok(()), ("starred", "is") => Ok(()), ("unread", "is") => Ok(()), _ => Err(format!( "Invalid condition: field='{}' operator='{}'", c.field, c.operator )), } } fn validate_input(input: &QueryFeedInput) -> Result<(), ApiError> { let name = input.name.trim(); if name.is_empty() { return Err(ApiError::bad_request("Name is required")); } if name.len() > 200 { return Err(ApiError::bad_request("Name must be 200 characters or fewer")); } for c in &input.rules { validate_condition(c).map_err(ApiError::bad_request)?; } Ok(()) } /// Count matching items for a set of conditions. async fn count_matches( db: &bb_db::Database, rules: &[QueryCondition], ) -> Result { let filter = FeedFilter::from_conditions(rules.to_vec()); let generator = FeedGenerator::new(db.clone()).with_filter(filter); Ok(generator.count().await?) } // ── Query feed commands ─────────────────────────────────────────── /// List all query feeds with match counts. #[tauri::command] #[instrument(skip_all)] pub async fn list_query_feeds( state: State<'_, Arc>, ) -> Result, ApiError> { let db = state.orchestrator.database(); let feeds = db.query_feeds().list_all().await?; let mut responses = Vec::with_capacity(feeds.len()); for feed in feeds { let rules = feed.rules_vec(); let match_count = count_matches(db, &rules).await?; responses.push(QueryFeedResponse { id: feed.id.to_string(), name: feed.name, rules, match_count, }); } Ok(responses) } /// Create a new query feed. #[tauri::command] #[instrument(skip_all)] pub async fn create_query_feed( state: State<'_, Arc>, input: QueryFeedInput, ) -> Result { validate_input(&input)?; let db = state.orchestrator.database(); let feed = db .query_feeds() .create(CreateQueryFeed { name: input.name.trim().to_string(), rules: input.rules.clone(), }) .await?; let rules = feed.rules_vec(); let match_count = count_matches(db, &rules).await?; Ok(QueryFeedResponse { id: feed.id.to_string(), name: feed.name, rules, match_count, }) } /// Update a query feed's name and rules. #[tauri::command] #[instrument(skip_all)] pub async fn update_query_feed( state: State<'_, Arc>, id: String, name: String, rules: Vec, ) -> Result<(), ApiError> { let feed_id: QueryFeedId = id .parse() .map_err(|_| ApiError::bad_request("Invalid query feed ID"))?; let input = QueryFeedInput { name: name.clone(), rules: rules.clone(), }; validate_input(&input)?; let db = state.orchestrator.database(); db.query_feeds() .update(feed_id, name.trim(), &rules) .await?; Ok(()) } /// Delete a query feed. #[tauri::command] #[instrument(skip_all)] pub async fn delete_query_feed( state: State<'_, Arc>, id: String, ) -> Result<(), ApiError> { let feed_id: QueryFeedId = id .parse() .map_err(|_| ApiError::bad_request("Invalid query feed ID"))?; state .orchestrator .database() .query_feeds() .delete(feed_id) .await?; Ok(()) } // ── Reader view ─────────────────────────────────────────────────── /// Response for reader view extraction. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ReaderViewResponse { pub title: String, pub content: String, pub text_content: String, } /// Extract readable article content from a URL using the reader view plugin. #[tauri::command] #[instrument(skip_all)] pub async fn extract_reader_view( state: State<'_, Arc>, url: String, ) -> Result { let plugins_dir = state .orchestrator .plugins() .read() .await .plugins_dir() .to_path_buf(); // Validate URL scheme to prevent file:// and other local access. let lower = url.to_ascii_lowercase(); if !lower.starts_with("http://") && !lower.starts_with("https://") { return Err(ApiError::bad_request("Only http and https URLs are allowed for reader view")); } // Run in a blocking task since Rhai engine and HTTP are synchronous. let result = tokio::task::spawn_blocking(move || { bb_core::rhai_plugin::run_reader_script(&url, &plugins_dir).map_err(|e| { ApiError::plugin(format!("Reader extraction failed: {}", e)) }) }) .await .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))??; Ok(ReaderViewResponse { title: result.title, content: result.content, text_content: result.text_content, }) } #[cfg(test)] mod tests { use super::*; #[test] fn validate_title_contains() { let c = QueryCondition { field: "title".into(), operator: "contains".into(), value: "rust".into(), }; assert!(validate_condition(&c).is_ok()); } #[test] fn validate_title_not_contains() { let c = QueryCondition { field: "title".into(), operator: "not_contains".into(), value: "spam".into(), }; assert!(validate_condition(&c).is_ok()); } #[test] fn validate_title_matches_regex() { let c = QueryCondition { field: "title".into(), operator: "matches_regex".into(), value: "rust.*lang".into(), }; assert!(validate_condition(&c).is_ok()); } #[test] fn validate_author_contains() { let c = QueryCondition { field: "author".into(), operator: "contains".into(), value: "alice".into(), }; assert!(validate_condition(&c).is_ok()); } #[test] fn validate_body_not_contains() { let c = QueryCondition { field: "body".into(), operator: "not_contains".into(), value: "ad".into(), }; assert!(validate_condition(&c).is_ok()); } #[test] fn validate_source_equals() { let c = QueryCondition { field: "source".into(), operator: "equals".into(), value: "rss".into(), }; assert!(validate_condition(&c).is_ok()); } #[test] fn validate_starred_is() { let c = QueryCondition { field: "starred".into(), operator: "is".into(), value: "true".into(), }; assert!(validate_condition(&c).is_ok()); } #[test] fn validate_unread_is() { let c = QueryCondition { field: "unread".into(), operator: "is".into(), value: "true".into(), }; assert!(validate_condition(&c).is_ok()); } #[test] fn validate_tag_equals() { let c = QueryCondition { field: "tag".into(), operator: "equals".into(), value: "rust".into(), }; assert!(validate_condition(&c).is_ok()); } #[test] fn validate_invalid_field() { let c = QueryCondition { field: "nonexistent".into(), operator: "contains".into(), value: "x".into(), }; assert!(validate_condition(&c).is_err()); } #[test] fn validate_invalid_operator() { let c = QueryCondition { field: "title".into(), operator: "invalid_op".into(), value: "x".into(), }; assert!(validate_condition(&c).is_err()); } #[test] fn validate_source_contains_invalid() { // source only supports "equals" let c = QueryCondition { field: "source".into(), operator: "contains".into(), value: "rss".into(), }; assert!(validate_condition(&c).is_err()); } #[test] fn validate_input_empty_name() { let input = QueryFeedInput { name: " ".into(), rules: vec![], }; assert!(validate_input(&input).is_err()); } #[test] fn validate_input_long_name() { let input = QueryFeedInput { name: "a".repeat(201), rules: vec![], }; assert!(validate_input(&input).is_err()); } #[test] fn validate_input_ok() { let input = QueryFeedInput { name: "My Filter".into(), rules: vec![QueryCondition { field: "title".into(), operator: "contains".into(), value: "rust".into(), }], }; assert!(validate_input(&input).is_ok()); } }