diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 29988c6..ef2d98b 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,2 +1,3 @@ pub mod credential; pub mod project; +pub mod tracker; diff --git a/src-tauri/src/models/tracker.rs b/src-tauri/src/models/tracker.rs new file mode 100644 index 0000000..252df15 --- /dev/null +++ b/src-tauri/src/models/tracker.rs @@ -0,0 +1,359 @@ +use rusqlite::{params, Connection, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfig { + pub analyst_command: String, + pub analyst_args: Vec, + pub developer_command: String, + pub developer_args: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterGroup { + pub conditions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Filter { + pub field: String, + pub operator: String, // "In", "NotIn", "Equals", "NotEquals" + pub value: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchedTracker { + pub id: String, + pub project_id: String, + pub tracker_id: i32, + pub tracker_label: String, + pub polling_interval: i32, + pub agent_config: AgentConfig, + pub filters: Vec, + pub enabled: bool, + pub last_polled_at: Option, + pub created_at: String, +} + +fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + let agent_config_json: String = row.get(5)?; + let filters_json: String = row.get(6)?; + let enabled_int: i32 = row.get(7)?; + + let agent_config: AgentConfig = serde_json::from_str(&agent_config_json) + .map_err(|e| rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(e)))?; + let filters: Vec = serde_json::from_str(&filters_json) + .map_err(|e| rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e)))?; + + Ok(WatchedTracker { + id: row.get(0)?, + project_id: row.get(1)?, + tracker_id: row.get(2)?, + tracker_label: row.get(3)?, + polling_interval: row.get(4)?, + agent_config, + filters, + enabled: enabled_int != 0, + last_polled_at: row.get(8)?, + created_at: row.get(9)?, + }) +} + +impl WatchedTracker { + pub fn insert( + conn: &Connection, + project_id: &str, + tracker_id: i32, + tracker_label: &str, + polling_interval: i32, + agent_config: AgentConfig, + filters: Vec, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + let agent_config_json = serde_json::to_string(&agent_config) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let filters_json = serde_json::to_string(&filters) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + + conn.execute( + "INSERT INTO watched_trackers (id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, now], + )?; + + Ok(WatchedTracker { + id, + project_id: project_id.to_string(), + tracker_id, + tracker_label: tracker_label.to_string(), + polling_interval, + agent_config, + filters, + enabled: true, + last_polled_at: None, + created_at: now, + }) + } + + pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \ + FROM watched_trackers WHERE project_id = ?1 ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![project_id], from_row)?; + rows.collect() + } + + pub fn list_all_enabled(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \ + FROM watched_trackers WHERE enabled = 1 ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([], from_row)?; + rows.collect() + } + + pub fn get_by_id(conn: &Connection, id: &str) -> Result { + conn.query_row( + "SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \ + FROM watched_trackers WHERE id = ?1", + params![id], + from_row, + ) + } + + pub fn update( + conn: &Connection, + id: &str, + polling_interval: i32, + agent_config: AgentConfig, + filters: Vec, + enabled: bool, + ) -> Result<()> { + let agent_config_json = serde_json::to_string(&agent_config) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let filters_json = serde_json::to_string(&filters) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let enabled_int = if enabled { 1i32 } else { 0i32 }; + + let affected = conn.execute( + "UPDATE watched_trackers SET polling_interval = ?1, agent_config_json = ?2, filters_json = ?3, enabled = ?4 WHERE id = ?5", + params![polling_interval, agent_config_json, filters_json, enabled_int, id], + )?; + + if affected == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + Ok(()) + } + + pub fn update_last_polled(conn: &Connection, id: &str) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let affected = conn.execute( + "UPDATE watched_trackers SET last_polled_at = ?1 WHERE id = ?2", + params![now, id], + )?; + if affected == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + Ok(()) + } + + pub fn delete(conn: &Connection, id: &str) -> Result<()> { + let affected = conn.execute("DELETE FROM watched_trackers WHERE id = ?1", params![id])?; + if affected == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::models::project::Project; + + fn setup() -> Connection { + let conn = db::init_in_memory().expect("db init should succeed"); + Project::insert(&conn, "Test Project", "/path/test", None, "main").unwrap(); + conn + } + + fn project_id(conn: &Connection) -> String { + Project::list(conn).unwrap().into_iter().next().unwrap().id + } + + fn sample_agent_config() -> AgentConfig { + AgentConfig { + analyst_command: "analyst".to_string(), + analyst_args: vec!["--mode".to_string(), "analyze".to_string()], + developer_command: "developer".to_string(), + developer_args: vec!["--fix".to_string()], + } + } + + fn sample_filters() -> Vec { + vec![FilterGroup { + conditions: vec![Filter { + field: "status".to_string(), + operator: "In".to_string(), + value: vec!["Open".to_string(), "In Progress".to_string()], + }], + }] + } + + #[test] + fn test_insert_tracker() { + let conn = setup(); + let pid = project_id(&conn); + + let tracker = WatchedTracker::insert( + &conn, + &pid, + 42, + "Bug Tracker", + 15, + sample_agent_config(), + sample_filters(), + ) + .expect("insert should succeed"); + + assert!(!tracker.id.is_empty()); + assert_eq!(tracker.project_id, pid); + assert_eq!(tracker.tracker_id, 42); + assert_eq!(tracker.tracker_label, "Bug Tracker"); + assert_eq!(tracker.polling_interval, 15); + assert!(tracker.enabled); + assert!(tracker.last_polled_at.is_none()); + assert!(!tracker.created_at.is_empty()); + assert_eq!(tracker.agent_config.analyst_command, "analyst"); + assert_eq!(tracker.filters.len(), 1); + } + + #[test] + fn test_list_by_project() { + let conn = setup(); + let pid = project_id(&conn); + + WatchedTracker::insert(&conn, &pid, 1, "Tracker A", 10, sample_agent_config(), vec![]).unwrap(); + WatchedTracker::insert(&conn, &pid, 2, "Tracker B", 20, sample_agent_config(), vec![]).unwrap(); + + let trackers = WatchedTracker::list_by_project(&conn, &pid).expect("list should succeed"); + assert_eq!(trackers.len(), 2); + } + + #[test] + fn test_list_all_enabled() { + let conn = setup(); + let pid = project_id(&conn); + + let t1 = WatchedTracker::insert(&conn, &pid, 1, "Enabled", 10, sample_agent_config(), vec![]).unwrap(); + let t2 = WatchedTracker::insert(&conn, &pid, 2, "Disabled", 10, sample_agent_config(), vec![]).unwrap(); + + // Disable t2 + WatchedTracker::update( + &conn, + &t2.id, + t2.polling_interval, + sample_agent_config(), + vec![], + false, + ) + .unwrap(); + + let enabled = WatchedTracker::list_all_enabled(&conn).expect("list_all_enabled should succeed"); + assert_eq!(enabled.len(), 1); + assert_eq!(enabled[0].id, t1.id); + } + + #[test] + fn test_get_by_id() { + let conn = setup(); + let pid = project_id(&conn); + + let created = WatchedTracker::insert( + &conn, + &pid, + 99, + "My Tracker", + 30, + sample_agent_config(), + sample_filters(), + ) + .unwrap(); + + let found = WatchedTracker::get_by_id(&conn, &created.id).expect("get_by_id should succeed"); + assert_eq!(found.id, created.id); + assert_eq!(found.tracker_id, 99); + assert_eq!(found.tracker_label, "My Tracker"); + assert_eq!(found.polling_interval, 30); + assert_eq!(found.filters.len(), 1); + } + + #[test] + fn test_update_tracker() { + let conn = setup(); + let pid = project_id(&conn); + + let created = WatchedTracker::insert( + &conn, + &pid, + 10, + "Original", + 5, + sample_agent_config(), + sample_filters(), + ) + .unwrap(); + + let new_filters = vec![FilterGroup { + conditions: vec![Filter { + field: "priority".to_string(), + operator: "Equals".to_string(), + value: vec!["High".to_string()], + }], + }]; + + WatchedTracker::update(&conn, &created.id, 60, sample_agent_config(), new_filters, false) + .expect("update should succeed"); + + let updated = WatchedTracker::get_by_id(&conn, &created.id).unwrap(); + assert_eq!(updated.polling_interval, 60); + assert!(!updated.enabled); + assert_eq!(updated.filters[0].conditions[0].field, "priority"); + } + + #[test] + fn test_update_last_polled() { + let conn = setup(); + let pid = project_id(&conn); + + let created = + WatchedTracker::insert(&conn, &pid, 5, "Poller", 10, sample_agent_config(), vec![]).unwrap(); + + assert!(created.last_polled_at.is_none()); + + WatchedTracker::update_last_polled(&conn, &created.id).expect("update_last_polled should succeed"); + + let updated = WatchedTracker::get_by_id(&conn, &created.id).unwrap(); + assert!(updated.last_polled_at.is_some()); + } + + #[test] + fn test_delete_tracker() { + let conn = setup(); + let pid = project_id(&conn); + + let created = + WatchedTracker::insert(&conn, &pid, 7, "ToDelete", 10, sample_agent_config(), vec![]).unwrap(); + + WatchedTracker::delete(&conn, &created.id).expect("delete should succeed"); + + let result = WatchedTracker::get_by_id(&conn, &created.id); + assert!(result.is_err()); + } +}