Fix Modrinth version number display issues and improve plugin matching algorithm

This commit is contained in:
Rbanh 2025-03-30 19:30:33 -04:00
parent 4adb291592
commit 78f22f65f4
3 changed files with 677 additions and 16 deletions

View File

@ -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
- [ ] Discord webhook integration
- [ ] Plugin search and browse functionality
- [ ] Plugin install from repository

View File

@ -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<ModrinthSearchHit>,
// 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<String>,
versions: Vec<String>, // List of supported Minecraft versions
downloads: u64,
icon_url: Option<String>,
latest_version: Option<String>, // 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<String>,
game_versions: Vec<String>,
downloads: u64,
icon_url: Option<String>,
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<String>,
date_published: String,
downloads: u64,
version_type: String, // e.g., "release", "beta", "alpha"
files: Vec<ModrinthVersionFile>,
game_versions: Vec<String>,
loaders: Vec<String>,
// 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<HttpClient>,
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<Vec<ModrinthVersion>, Box<dyn Error + Send + Sync>> {
let url = format!("{}/project/{}/version", self.api_base_url, plugin_id);
let response_body = self.client.get(&url).await?;
let versions: Vec<ModrinthVersion> = 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<Vec<ModrinthVersion>, Box<dyn Error + Send + Sync>> {
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<ModrinthVersion> = 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<ModrinthUser, Box<dyn Error + Send + Sync>> {
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<RepositoryPlugin, String> {
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<String, String> {
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<Vec<RepositoryPlugin>, 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<RepositoryPlugin, String> {
// 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<String, String> {
// Just delegate to our server-type aware version with None
self.download_plugin_with_server_type(plugin_id, version_number_str, destination, None).await
}
}

View File

@ -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<Plugin>,
repositories_to_check: Vec<RepositorySource>
) -> Result<Vec<Plugin>, 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<RepositorySource>,
) -> 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
}
}