diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a201eec9..4788dd5f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6174,6 +6174,7 @@ dependencies = [ "tokio-test", "tracing", "tracing-subscriber", + "url", "urlencoding", "uuid", "warp", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aa83b3c3..5fb2e7ae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,7 @@ lazy_static = "1.4" warp = "0.3" urlencoding = "2" infer = "0.15" +url = "2.5.8" [dev-dependencies] tokio-test = "0.4" diff --git a/src-tauri/src/integrations/azuredevops_search.rs b/src-tauri/src/integrations/azuredevops_search.rs index c30d2c60..db1700d4 100644 --- a/src-tauri/src/integrations/azuredevops_search.rs +++ b/src-tauri/src/integrations/azuredevops_search.rs @@ -1,6 +1,12 @@ use super::confluence_search::SearchResult; use crate::integrations::query_expansion::expand_query; +const MAX_EXPANDED_QUERIES: usize = 3; + +fn escape_wiql(s: &str) -> String { + s.replace('\'', "''") +} + /// Search Azure DevOps Wiki for content matching the query pub async fn search_wiki( org_url: &str, @@ -15,7 +21,7 @@ pub async fn search_wiki( let mut all_results = Vec::new(); - for expanded_query in expanded_queries.iter().take(3) { + for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) { // Use Azure DevOps Search API let search_url = format!( "{}/_apis/search/wikisearchresults?api-version=7.0", @@ -30,10 +36,7 @@ pub async fn search_wiki( } }); - tracing::info!( - "Searching Azure DevOps Wiki with expanded query: {}", - search_url - ); + tracing::info!("Searching Azure DevOps Wiki with query: {}", expanded_query); let resp = client .post(&search_url) @@ -58,7 +61,7 @@ pub async fn search_wiki( .map_err(|e| format!("Failed to parse ADO wiki search response: {e}"))?; if let Some(results_array) = json["results"].as_array() { - for item in results_array.iter().take(3) { + for item in results_array.iter().take(MAX_EXPANDED_QUERIES) { let title = item["fileName"].as_str().unwrap_or("Untitled").to_string(); let path = item["path"].as_str().unwrap_or(""); @@ -165,22 +168,26 @@ pub async fn search_work_items( let mut all_results = Vec::new(); - for expanded_query in expanded_queries.iter().take(3) { + for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) { // Use WIQL (Work Item Query Language) let wiql_url = format!( "{}/_apis/wit/wiql?api-version=7.0", org_url.trim_end_matches('/') ); + let safe_query = escape_wiql(expanded_query); let wiql_query = format!( - "SELECT [System.Id], [System.Title], [System.Description], [System.State] FROM WorkItems WHERE [System.TeamProject] = '{project}' AND ([System.Title] CONTAINS '{expanded_query}' OR [System.Description] CONTAINS '{expanded_query}') ORDER BY [System.ChangedDate] DESC" + "SELECT [System.Id], [System.Title], [System.Description], [System.State] FROM WorkItems WHERE [System.TeamProject] = '{project}' AND ([System.Title] CONTAINS '{safe_query}' OR [System.Description] CONTAINS '{safe_query}') ORDER BY [System.ChangedDate] DESC" ); let wiql_body = serde_json::json!({ "query": wiql_query }); - tracing::info!("Searching Azure DevOps work items with expanded query"); + tracing::info!( + "Searching Azure DevOps work items with query: {}", + expanded_query + ); let resp = client .post(&wiql_url) @@ -203,7 +210,7 @@ pub async fn search_work_items( if let Some(work_items) = json["workItems"].as_array() { // Fetch details for top 3 work items - for item in work_items.iter().take(3) { + for item in work_items.iter().take(MAX_EXPANDED_QUERIES) { if let Some(id) = item["id"].as_i64() { if let Ok(work_item) = fetch_work_item_details(org_url, id, &cookie_header).await diff --git a/src-tauri/src/integrations/confluence_search.rs b/src-tauri/src/integrations/confluence_search.rs index e3874156..5c0f9acc 100644 --- a/src-tauri/src/integrations/confluence_search.rs +++ b/src-tauri/src/integrations/confluence_search.rs @@ -1,7 +1,10 @@ use serde::{Deserialize, Serialize}; +use url::Url; use super::query_expansion::expand_query; +const MAX_EXPANDED_QUERIES: usize = 3; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchResult { pub title: String, @@ -11,6 +14,25 @@ pub struct SearchResult { pub source: String, } +fn canonicalize_url(url: &str) -> String { + Url::parse(url) + .ok() + .map(|u| { + let mut u = u.clone(); + u.set_fragment(None); + u.set_query(None); + u.to_string() + }) + .unwrap_or_else(|| url.to_string()) +} + +fn escape_cql(s: &str) -> String { + s.replace('"', "\\\"") + .replace(')', "\\)") + .replace('(', "\\(") + .replace('~', "\\~") +} + /// Search Confluence for content matching the query /// /// This function expands the user query with related terms, synonyms, and variations @@ -27,11 +49,12 @@ pub async fn search_confluence( let mut all_results = Vec::new(); - for expanded_query in expanded_queries.iter().take(3) { + for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) { + let safe_query = escape_cql(expanded_query); let search_url = format!( "{}/rest/api/search?cql=text~\"{}\"&limit=5", base_url.trim_end_matches('/'), - urlencoding::encode(expanded_query) + urlencoding::encode(&safe_query) ); tracing::info!("Searching Confluence with expanded query: {}", search_url); @@ -100,8 +123,8 @@ pub async fn search_confluence( } } - all_results.sort_by(|a, b| a.url.cmp(&b.url)); - all_results.dedup_by(|a, b| a.url == b.url); + all_results.sort_by(|a, b| canonicalize_url(&a.url).cmp(&canonicalize_url(&b.url))); + all_results.dedup_by(|a, b| canonicalize_url(&a.url) == canonicalize_url(&b.url)); Ok(all_results) } diff --git a/src-tauri/src/integrations/query_expansion.rs b/src-tauri/src/integrations/query_expansion.rs index 66c13b6b..cd1a9b5b 100644 --- a/src-tauri/src/integrations/query_expansion.rs +++ b/src-tauri/src/integrations/query_expansion.rs @@ -78,6 +78,10 @@ fn get_product_synonyms(query: &str) -> Vec { /// A vector of query strings to search, with the original query first /// followed by expanded variations pub fn expand_query(query: &str) -> Vec { + if query.trim().is_empty() { + return Vec::new(); + } + let mut expanded = vec![query.to_string()]; // Get product synonyms diff --git a/src-tauri/src/integrations/servicenow_search.rs b/src-tauri/src/integrations/servicenow_search.rs index 5d1255e6..fb34b9d9 100644 --- a/src-tauri/src/integrations/servicenow_search.rs +++ b/src-tauri/src/integrations/servicenow_search.rs @@ -1,6 +1,8 @@ use super::confluence_search::SearchResult; use crate::integrations::query_expansion::expand_query; +const MAX_EXPANDED_QUERIES: usize = 3; + /// Search ServiceNow Knowledge Base for content matching the query pub async fn search_servicenow( instance_url: &str, @@ -14,7 +16,7 @@ pub async fn search_servicenow( let mut all_results = Vec::new(); - for expanded_query in expanded_queries.iter().take(3) { + for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) { // Search Knowledge Base articles let search_url = format!( "{}/api/now/table/kb_knowledge?sysparm_query=textLIKE{}^ORshort_descriptionLIKE{}&sysparm_limit=5", @@ -23,7 +25,7 @@ pub async fn search_servicenow( urlencoding::encode(expanded_query) ); - tracing::info!("Searching ServiceNow with expanded query: {}", search_url); + tracing::info!("Searching ServiceNow with query: {}", expanded_query); let resp = client .get(&search_url) @@ -46,7 +48,7 @@ pub async fn search_servicenow( .map_err(|e| format!("Failed to parse ServiceNow search response: {e}"))?; if let Some(result_array) = json["result"].as_array() { - for item in result_array.iter().take(3) { + for item in result_array.iter().take(MAX_EXPANDED_QUERIES) { // Take top 3 results let title = item["short_description"] .as_str() @@ -107,7 +109,7 @@ pub async fn search_incidents( let mut all_results = Vec::new(); - for expanded_query in expanded_queries.iter().take(3) { + for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) { // Search incidents let search_url = format!( "{}/api/now/table/incident?sysparm_query=short_descriptionLIKE{}^ORdescriptionLIKE{}&sysparm_limit=3&sysparm_display_value=true", @@ -117,8 +119,8 @@ pub async fn search_incidents( ); tracing::info!( - "Searching ServiceNow incidents with expanded query: {}", - search_url + "Searching ServiceNow incidents with query: {}", + expanded_query ); let resp = client