diff --git a/ROADMAP.md b/ROADMAP.md index bdae66d..bc8dca3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,33 +6,75 @@ - [x] Setup basic project structure - [x] Create Remote repository (optional) -## Core Infrastructure (In Progress) +## Core Infrastructure (Completed) ✅ - [x] Setup SQLite or JSON storage for plugin data - [x] Create core data models - [x] Build server/plugin directory scanner (basic implementation) - [x] Implement asynchronous server scanning (non-blocking) - [x] Implement JAR file parser for plugin.yml extraction -## Plugin Discovery (Needs Refinement) -- [ ] Improve server type detection (Paper/Spigot distinction, etc.) +## Plugin Discovery (Completed) ✅ +- [x] Improve server type detection (Paper/Spigot distinction, etc.) - [x] Implement plugins folder detection logic - [x] Design plugin metadata extraction system + - [x] Extract basic fields (name, version, author, etc.) + - [x] Extract website field from plugin.yml - [x] Build plugin hash identification system +- [x] Create plugin matching algorithm -## Web Crawler Development (In Progress) +## Web Crawler Development (Mostly Completed) - [x] Create base web crawler architecture - [x] Implement HangarMC crawler -- [ ] Implement SpigotMC crawler -- [ ] Implement Modrinth crawler +- [x] Implement SpigotMC data source using SpiGet API + - [x] Basic implementation + - [x] Fix version information retrieval issues + - [ ] Handle rate limiting and 403 errors +- [x] Implement Modrinth crawler + - [x] Basic implementation + - [x] Version compatibility checking + - [x] Fix version number display issues + - [ ] Handle rate limit issues - [ ] Implement GitHub releases crawler -- [ ] Create plugin matching algorithm + - [x] Basic implementation + - [ ] Complete API authentication -## Update Management (Upcoming) -- [ ] Build update detection system -- [ ] Implement changelog extraction -- [ ] Create plugin backup functionality -- [ ] Develop plugin replacement logic -- [ ] Add server restart capabilities (optional) +## Update Management (Mostly Completed) +- [x] Build update detection system (Basic logic) +- [x] Improve update detection reliability + - [x] Correctly fetch detailed version information from repositories + - [x] Implement robust version comparison logic (handling non-SemVer, suffixes, etc.) + - [x] Implement proper request headers (User-Agent, etc.) to mimic browser behavior + - [x] Add delays between API requests to mitigate rate limiting + - [x] Implement automatic retry logic for rate limits (429) and network errors + - [x] Implement API response caching (reduce requests, improve performance) + - [x] Implement intelligent name variation searches for plugins + - [x] Fix SpigotMC version retrieval issues + - [x] Fix update check progress getting stuck + - [x] Complete Modrinth integration + - [x] Fix incorrect plugin matching issues + - [x] Improve matching algorithm to reduce false positives + - [ ] Complete GitHub integration + - [ ] Add GitHub API authentication (optional token) + - [ ] Investigate SpigotMC 403 errors (potentially add headers/delay) +- [x] Implement changelog extraction +- [x] Create plugin backup functionality +- [x] Develop plugin replacement logic +- [x] Implement plugin source persistence (store identified repo/ID to avoid searching) + - [ ] Allow manual setting/clearing of persistent source +- [x] Add plugin update UI integration + - [x] Add update buttons to plugin listing and details view + - [x] Show update progress with loading indicators + - [x] Handle file access errors gracefully + - [x] Ensure correct version numbering in filenames + - [ ] Present multiple potential matches for ambiguous plugins + - [ ] Make version numbers clickable links to repository sources + - [ ] Allow user selection of correct plugin match when multiple are found +- [ ] Server platform compatibility matching (High Priority) + - [ ] Detect server platform and version accurately (Paper, Spigot, Forge, NeoForge, Fabric, etc.) + - [ ] Filter plugin updates to match the server platform + - [ ] Prevent incompatible version updates + - [ ] Add platform indicators in the UI for available updates + - [ ] Allow manual selection of target platform when multiple are available ## UI Development (In Progress) - [x] Design and implement main dashboard @@ -41,8 +83,15 @@ - [x] Build server folder selection interface - [x] Implement plugin detail view - [x] Add update notification system +- [x] Add per-plugin update check button & loading indicator +- [x] Add visual feedback for bulk update check (progress, loading state) +- [x] Implement error handling in UI with user-friendly messages - [ ] Create settings panel - [x] Implement dark mode +- [ ] Implement plugin matching disambiguation UI +- [ ] Add clickable version links to repository pages +- [ ] Add error recovery UI for failed updates +- [ ] Add detailed progress logging in UI for debugging ## Security Features (Upcoming) - [ ] Implement sandboxing for network requests @@ -51,14 +100,18 @@ ## Testing & Refinement (Upcoming) - [ ] Comprehensive testing with various server setups -- [ ] Performance optimization -- [ ] Error handling improvements +- [ ] Performance optimization (especially concurrent requests) +- [ ] Error handling improvements (parsing, network, etc.) - [ ] User acceptance testing +- [ ] Add automated tests for critical functionality +- [ ] Add error telemetry (optional) ## Documentation (Upcoming) - [ ] Create user documentation - [ ] Write developer documentation - [ ] Create installation guide +- [ ] Add troubleshooting guide +- [ ] Document known limitations and workarounds ## Deployment (Upcoming) - [ ] Prepare release process @@ -74,4 +127,6 @@ - [ ] Scheduled plugin update checks - [ ] Rollback system - [ ] Plugin recommendation system -- [ ] Discord webhook integration \ No newline at end of file +- [ ] Discord webhook integration +- [ ] Plugin search and browse functionality +- [ ] Plugin install from repository \ No newline at end of file diff --git a/src-tauri/src/crawlers/modrinth.rs b/src-tauri/src/crawlers/modrinth.rs new file mode 100644 index 0000000..ee124da --- /dev/null +++ b/src-tauri/src/crawlers/modrinth.rs @@ -0,0 +1,328 @@ +use std::error::Error; +use std::path::Path; +use serde::{Serialize, Deserialize}; +use crate::{HttpClient, ServerType}; +use crate::models::repository::{RepositoryPlugin, RepositorySource}; +use crate::platform_matcher::is_version_compatible_with_server; +use urlencoding; +use async_trait::async_trait; +use std::sync::Arc; +use crate::crawlers::Repository; + +// Modrinth API response structures (Based on https://docs.modrinth.com/api-spec/) + +#[derive(Debug, Serialize, Deserialize)] +struct ModrinthSearchResponse { + hits: Vec, + // Omitting pagination fields (offset, limit, total_hits) for simplicity +} + +#[derive(Debug, Serialize, Deserialize)] +struct ModrinthSearchHit { + project_id: String, + slug: String, // Use this or project_id for details requests + project_type: String, // e.g., "mod", "plugin" + author: String, // Sometimes different from the project owner + title: String, + description: String, + categories: Vec, + versions: Vec, // List of supported Minecraft versions + downloads: u64, + icon_url: Option, + latest_version: Option, // Version number of the latest version + date_modified: String, + // Omitting client_side, server_side, display_categories, gallery, featured_gallery, license, follows +} + +#[derive(Debug, Serialize, Deserialize)] +struct ModrinthProject { + id: String, + slug: String, + project_type: String, + team: String, // Team ID, need another call potentially for author names? + title: String, + description: String, + body: String, // Full description + categories: Vec, + game_versions: Vec, + downloads: u64, + icon_url: Option, + published: String, + updated: String, + // Omitting donation_urls, license, client_side, server_side, gallery, status, moderator_message, versions + // We might need the 'versions' array (list of version IDs) for getting specific version details +} + +// Structure for the response from /project/{id}/version +#[derive(Debug, Serialize, Deserialize)] +struct ModrinthVersion { + id: String, + project_id: String, + author_id: String, // User ID, need another call for name? + name: String, // Version title + version_number: String, + changelog: Option, + date_published: String, + downloads: u64, + version_type: String, // e.g., "release", "beta", "alpha" + files: Vec, + game_versions: Vec, + loaders: Vec, + // Omitting dependencies, featured, status, requested_status +} + +#[derive(Debug, Serialize, Deserialize)] +struct ModrinthVersionFile { + hashes: ModrinthHashes, + url: String, + filename: String, + primary: bool, + size: u64, + // Omitting file_type (useful if filtering non-JARs) +} + +#[derive(Debug, Serialize, Deserialize)] +struct ModrinthHashes { + sha1: String, + sha512: String, +} + +// Modrinth crawler implementation +pub struct ModrinthCrawler { + client: Arc, + api_base_url: String, +} + +impl ModrinthCrawler { + pub fn new() -> Self { + ModrinthCrawler { + client: Arc::new(HttpClient::new()), + api_base_url: "https://api.modrinth.com/v2".to_string(), + } + } + + // Helper to get versions, now async + async fn get_project_versions_internal(&self, plugin_id: &str) -> Result, Box> { + let url = format!("{}/project/{}/version", self.api_base_url, plugin_id); + let response_body = self.client.get(&url).await?; + let versions: Vec = serde_json::from_str(&response_body)?; + Ok(versions) + } + + // Helper to get compatible versions for a server type + async fn get_compatible_versions(&self, plugin_id: &str, server_type: Option<&ServerType>) -> Result, Box> { + let all_versions = self.get_project_versions_internal(plugin_id).await?; + + // If no server type provided, return all versions + if server_type.is_none() { + return Ok(all_versions); + } + + let server_type = server_type.unwrap(); + let compatible_versions: Vec = all_versions.into_iter() + .filter(|version| { + // Check if this version is compatible with our server + is_version_compatible_with_server(&version.loaders, server_type) + }) + .collect(); + + // If no compatible versions found, log a warning + if compatible_versions.is_empty() { + println!("[ModrinthCrawler::get_compatible_versions] Warning: No versions compatible with server type {:?} for plugin {}", server_type, plugin_id); + } + + Ok(compatible_versions) + } + + // Helper to get user details from ID, now async + async fn get_user_details(&self, user_id: &str) -> Result> { + let url = format!("{}/user/{}", self.api_base_url, user_id); + let response_body = self.client.get(&url).await?; + let user: ModrinthUser = serde_json::from_str(&response_body)?; + Ok(user) + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct ModrinthUser { + username: String, + // other fields like id, name, avatar_url etc. are available if needed +} + +impl ModrinthCrawler { + pub async fn get_plugin_details_with_server_type(&self, plugin_id: &str, server_type: Option<&ServerType>) -> Result { + let project_url = format!("{}/project/{}", self.api_base_url, plugin_id); + let project_response_body = match self.client.get(&project_url).await { + Ok(body) => body, + Err(e) => return Err(format!("Modrinth project request failed: {}", e)), + }; + let project: ModrinthProject = match serde_json::from_str(&project_response_body) { + Ok(p) => p, + Err(e) => return Err(format!("Failed to parse Modrinth project: {}", e)), + }; + + let page_url = format!("https://modrinth.com/plugin/{}", project.slug); + + // Fetch compatible versions + let versions = match self.get_compatible_versions(&project.id, server_type).await { + Ok(v) => v, + Err(e) => { + println!("Failed to fetch Modrinth versions for {}: {}", plugin_id, e); + Vec::new() // Continue with empty versions if fetch fails + } + }; + + let latest_version_opt = versions.first(); + + // Explicitly use version_number instead of id + let latest_version_number = latest_version_opt.map_or("Unknown".to_string(), |v| { + // Always use the version_number field which is the actual semantic version + // not the internal Modrinth ID + v.version_number.clone() + }); + + let changelog = latest_version_opt.and_then(|v| v.changelog.clone()); + let author_id = latest_version_opt.map(|v| v.author_id.clone()); + + // Fetch author name if ID is available + let author_name = if let Some(id) = author_id { + match self.get_user_details(&id).await { + Ok(user) => user.username, + Err(e) => { + println!("Failed to fetch Modrinth author details for ID {}: {}", id, e); + "Unknown Author".to_string() + } + } + } else { + project.team.clone() // Fallback to team ID if no version author found + }; + + let primary_file = latest_version_opt.and_then(|v| v.files.iter().find(|f| f.primary)); + + Ok(RepositoryPlugin { + id: project.id.clone(), + name: project.title, + version: latest_version_number, + description: Some(project.description), + authors: vec![author_name], + download_url: primary_file.map_or(String::new(), |f| f.url.clone()), + repository: RepositorySource::Modrinth, + page_url, + download_count: Some(project.downloads), + last_updated: Some(project.updated), + icon_url: project.icon_url, + minecraft_versions: latest_version_opt.map_or(project.game_versions.clone(), |v| v.game_versions.clone()), + categories: project.categories, + rating: None, // Modrinth API doesn't provide rating directly + file_size: primary_file.map(|f| f.size), + file_hash: primary_file.map(|f| f.hashes.sha512.clone()), // Use SHA512 + changelog, + }) + } + + pub async fn download_plugin_with_server_type(&self, plugin_id: &str, version_number_str: &str, destination: &Path, server_type: Option<&ServerType>) -> Result { + let versions = match self.get_compatible_versions(plugin_id, server_type).await { + Ok(v) => v, + Err(e) => return Err(format!("Failed to get Modrinth versions for download: {}", e)), + }; + + let target_version = versions.iter().find(|v| v.version_number == version_number_str); + + let version_to_download = match target_version { + Some(v) => v, + None => return Err(format!("Version '{}' not found or not compatible for plugin {}", version_number_str, plugin_id)), + }; + + let primary_file = match version_to_download.files.iter().find(|f| f.primary) { + Some(f) => f, + None => return Err(format!("No primary file found for version '{}' of plugin {}", version_number_str, plugin_id)), + }; + + match self.client.download(&primary_file.url, destination).await { + Ok(_) => Ok(destination.to_string_lossy().to_string()), + Err(e) => Err(format!("Failed to download from Modrinth: {}", e)), + } + } +} + +#[async_trait] +impl Repository for ModrinthCrawler { + fn get_repository_name(&self) -> String { + "Modrinth".to_string() + } + + async fn search(&self, query: &str) -> Result, String> { + let encoded_query = urlencoding::encode(query); + // Corrected format string with proper quoting for facets + let url = format!( + "{}/search?query={}&facets=[[\"project_type:plugin\"]]&limit=20", + self.api_base_url, + encoded_query + ); + + let response_body = match self.client.get(&url).await { + Ok(body) => body, + Err(e) => return Err(format!("Modrinth search request failed: {}", e)), + }; + + let search_response: ModrinthSearchResponse = match serde_json::from_str(&response_body) { + Ok(sr) => sr, + Err(e) => return Err(format!("Failed to parse Modrinth search results: {}", e)), + }; + + let mut results = Vec::new(); + for hit in search_response.hits { + // For search results, we don't have version_number directly, + // so we need to fetch the latest version for each project + let version_info = if let Some(latest_version) = &hit.latest_version { + // If latest_version looks like an ID (no dots and long), fetch the real version number + if latest_version.len() >= 8 && !latest_version.contains('.') { + match self.get_project_versions_internal(&hit.project_id).await { + Ok(versions) if !versions.is_empty() => { + versions[0].version_number.clone() + }, + _ => latest_version.clone() + } + } else { + latest_version.clone() + } + } else { + "Unknown".to_string() + }; + + // Create a simplified repository plugin from the search hit + let repo_plugin = RepositoryPlugin { + id: hit.project_id.clone(), + name: hit.title, + version: version_info, + description: Some(hit.description), + authors: vec![hit.author], + download_url: String::new(), // Will be filled in when getting specific version + repository: RepositorySource::Modrinth, + page_url: format!("https://modrinth.com/plugin/{}", hit.slug), + download_count: Some(hit.downloads), + last_updated: Some(hit.date_modified), + icon_url: hit.icon_url, + minecraft_versions: hit.versions, + categories: hit.categories, + rating: None, + file_size: None, + file_hash: None, + changelog: None, + }; + results.push(repo_plugin); + } + + Ok(results) + } + + async fn get_plugin_details(&self, plugin_id: &str) -> Result { + // Just delegate to our server-type aware version with None + self.get_plugin_details_with_server_type(plugin_id, None).await + } + + async fn download_plugin(&self, plugin_id: &str, version_number_str: &str, destination: &Path) -> Result { + // Just delegate to our server-type aware version with None + self.download_plugin_with_server_type(plugin_id, version_number_str, destination, None).await + } +} \ No newline at end of file diff --git a/src-tauri/src/services/update_manager/update_checker.rs b/src-tauri/src/services/update_manager/update_checker.rs new file mode 100644 index 0000000..ba67612 --- /dev/null +++ b/src-tauri/src/services/update_manager/update_checker.rs @@ -0,0 +1,278 @@ +use tauri::{AppHandle, Emitter}; +use std::error::Error; +use futures::{future, stream::StreamExt}; +use tauri::async_runtime::spawn; + +use crate::models::plugin::Plugin; +use crate::models::repository::{RepositorySource, RepositoryPlugin, BulkUpdateProgress, SingleUpdateResult}; +use super::version_utils::compare_plugin_versions; + +/// Check for updates for multiple plugins +pub async fn check_for_plugin_updates( + app_handle: AppHandle, + installed_plugins: Vec, + repositories_to_check: Vec +) -> Result, String> { + if installed_plugins.is_empty() { + return Ok(Vec::new()); + } + + // Clone plugins for returning + let mut updated_plugins = installed_plugins.clone(); + + // Initialize progress + let total = installed_plugins.len(); + println!("Starting bulk update check for {} plugins", total); + + if let Err(e) = app_handle.emit("bulk_update_start", total) { + return Err(format!("Failed to emit bulk_update_start event: {}", e)); + } + + // Process plugins in batches to avoid overwhelming the system + let batch_size = 5; + for (batch_index, chunk) in installed_plugins.chunks(batch_size).enumerate() { + let mut batch_futures = Vec::new(); + + // Create futures for this batch + for (i, plugin) in chunk.iter().enumerate() { + let index = batch_index * batch_size + i; + let app_handle_clone = app_handle.clone(); + let repos_clone = repositories_to_check.clone(); + let plugin_clone = plugin.clone(); + + // Report progress + let progress = BulkUpdateProgress { + processed: index, + total, + current_plugin_name: plugin.name.clone(), + }; + + if let Err(e) = app_handle_clone.emit("update_check_progress", progress) { + println!("Failed to emit update progress: {}", e); + } + + // Add to batch + batch_futures.push(async move { + // Process plugin + println!("Processing update for plugin {}/{}: {}", index + 1, total, plugin_clone.name); + let result = process_plugin_update(plugin_clone.clone(), &repos_clone).await; + (index, result) + }); + } + + // Process batch concurrently + let batch_results = futures::future::join_all(batch_futures).await; + + // Update plugins with results from this batch + for (index, updated_plugin) in batch_results { + if let Some(position) = updated_plugins.iter().position(|p| p.file_path == updated_plugin.file_path) { + updated_plugins[position] = updated_plugin; + println!("Updated plugin {}: {}", index, updated_plugins[position].name); + if let Some(latest) = &updated_plugins[position].latest_version { + println!(" Current version: {}, Latest version: {}, Has update: {}", + updated_plugins[position].version, latest, updated_plugins[position].has_update); + } + } + } + + // Small delay between batches to avoid overwhelming + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + // Final progress report + let final_progress = BulkUpdateProgress { + processed: total, + total, + current_plugin_name: "Completed".to_string(), + }; + + if let Err(e) = app_handle.emit("update_check_progress", final_progress) { + println!("Failed to emit final update progress: {}", e); + } + + println!("Bulk update check completed for {} plugins", total); + Ok(updated_plugins) +} + +/// Check for updates for a single plugin +pub async fn check_single_plugin_update( + app_handle: AppHandle, + plugin_to_check: Plugin, + repositories_to_check: Vec, +) -> Result<(), String> { // Returns Result<(), String> because result is sent via event + // Begin check + if let Err(e) = app_handle.emit("single_update_check_started", plugin_to_check.name.clone()) { + return Err(format!("Failed to emit single_update_check_started event: {}", e)); + } + + // Process update + let updated_plugin = process_plugin_update(plugin_to_check.clone(), &repositories_to_check).await; + + // Create result + let result = SingleUpdateResult { + original_file_path: plugin_to_check.file_path.clone(), + plugin: Some(updated_plugin), + error: None, + }; + + // Emit result + if let Err(e) = app_handle.emit("single_update_check_completed", result) { + return Err(format!("Failed to emit single_update_check_completed event: {}", e)); + } + + Ok(()) +} + +/// Process update check for a single plugin +async fn process_plugin_update(plugin: Plugin, repositories: &[RepositorySource]) -> Plugin { + let mut updated_plugin = plugin.clone(); + println!("Checking for updates for plugin: {}", plugin.name); + + // If plugin already has repository info, check that specific repo + if let (Some(repo_source), Some(repo_id)) = (&plugin.repository_source, &plugin.repository_id) { + println!("Plugin has existing repository info: {:?}, ID: {}", repo_source, repo_id); + match crate::lib_get_plugin_details_from_repository(repo_id, repo_source.clone(), None).await { + Ok(repo_plugin) => { + println!("Successfully got details from repository for {}", plugin.name); + update_plugin_from_repo(&mut updated_plugin, &repo_plugin); + }, + Err(e) => { + println!("Failed to get update details for {}: {}", plugin.name, e); + } + } + return updated_plugin; + } + + // Otherwise, try smart matching with repositories + println!("No repository info for {}. Trying smart matching...", plugin.name); + let matches = match crate::search_with_variations(&plugin.name, repositories).await { + Ok(matches) => { + println!("Found {} potential matches for {}", matches.len(), plugin.name); + matches + }, + Err(e) => { + println!("Error searching for plugin {}: {}", plugin.name, e); + return updated_plugin; + } + }; + + // Find best match + for repo_plugin in matches { + if is_likely_match(&plugin, &repo_plugin) { + println!("Found likely match for {} in {:?}: {} ({})", + plugin.name, repo_plugin.repository, repo_plugin.name, repo_plugin.version); + update_plugin_from_repo(&mut updated_plugin, &repo_plugin); + break; + } + } + + println!("Update check complete for plugin: {}", plugin.name); + updated_plugin +} + +/// Update plugin with repository information +fn update_plugin_from_repo(plugin: &mut Plugin, repo_plugin: &RepositoryPlugin) { + // Set repository information + plugin.repository_source = Some(repo_plugin.repository.clone()); + plugin.repository_id = Some(repo_plugin.id.clone()); + plugin.repository_url = Some(repo_plugin.page_url.clone()); + + // For Modrinth, check if the version looks like an ID instead of a version number + let latest_version = if repo_plugin.repository == RepositorySource::Modrinth && + repo_plugin.version.len() >= 8 && + !repo_plugin.version.contains('.') { + // This looks like an ID rather than a version, use "Latest" as a fallback + "Latest".to_string() + } else { + repo_plugin.version.clone() + }; + + // Set latest version if newer + if latest_version != plugin.version { + let has_update = compare_plugin_versions(&plugin.version, &latest_version); + plugin.latest_version = Some(latest_version); + plugin.has_update = has_update; + plugin.changelog = repo_plugin.changelog.clone(); + } else { + plugin.has_update = false; + } +} + +/// Determine if a repository plugin is likely a match for the installed plugin +fn is_likely_match(installed: &Plugin, repo: &RepositoryPlugin) -> bool { + // Require multiple criteria to match to avoid false positives + let mut match_points = 0; + + // Name similarity check + let name_similarity = calculate_name_similarity(&installed.name, &repo.name); + + // Strong name match (over 0.9 similarity) + if name_similarity > 0.9 { + match_points += 2; + } + // Moderate name match + else if name_similarity > 0.7 { + match_points += 1; + } + + // Author check - if any author matches + if !installed.authors.is_empty() && !repo.authors.is_empty() { + for installed_author in &installed.authors { + for repo_author in &repo.authors { + if installed_author.to_lowercase() == repo_author.to_lowercase() { + match_points += 2; + break; + } + } + } + } + + // Website match + if let Some(website) = &installed.website { + if website.contains(&repo.page_url) || repo.page_url.contains(website) { + match_points += 2; + } + } + + // Require at least 2 match points to consider it a likely match + // This helps avoid incorrect matches + match_points >= 2 +} + +/// Calculate similarity between plugin names (simple implementation) +fn calculate_name_similarity(name1: &str, name2: &str) -> f32 { + let name1_lower = name1.to_lowercase(); + let name2_lower = name2.to_lowercase(); + + // Direct match + if name1_lower == name2_lower { + return 1.0; + } + + // Contains check + if name1_lower.contains(&name2_lower) || name2_lower.contains(&name1_lower) { + return 0.9; + } + + // Simple word matching + let words1: Vec<&str> = name1_lower.split_whitespace().collect(); + let words2: Vec<&str> = name2_lower.split_whitespace().collect(); + + let mut matched_words = 0; + let total_words = words1.len().max(words2.len()); + + for word1 in &words1 { + for word2 in &words2 { + if word1 == word2 && word1.len() > 2 { // Ignore short words + matched_words += 1; + break; + } + } + } + + if total_words > 0 { + matched_words as f32 / total_words as f32 + } else { + 0.0 + } +} \ No newline at end of file