use std::path::Path; use models::repo::repo_license; use models::repo::repos; use sea_orm::{ActiveModelTrait, DatabaseTransaction, EntityTrait, Set}; use crate::sync::error::{SyncError, SyncResult}; const LICENSE_FILES: &[&str] = &[ "LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "COPYING.md", ]; pub async fn sync_repo_license( txn: &DatabaseTransaction, repo_dir: &Path, repo: &repos::Model, ) -> SyncResult<()> { let Some(name) = repo.license_name.clone() else { return Ok(()); }; let body = read_license_body(repo_dir).await?; let display_name = infer_display_name(&name, body.as_deref()); let description = infer_description(&name, body.as_deref()); let url = infer_url(&name); let now = time::OffsetDateTime::now_utc(); if let Some(existing) = repo_license::Entity::find_by_id(name.clone()) .one(txn) .await? { let mut am: repo_license::ActiveModel = existing.into(); am.display_name = Set(display_name); am.description = Set(description); if let Some(text) = body.clone() { am.body = Set(text); } am.url = Set(url); am.updated_at = Set(now); am.update(txn).await?; } else { let am = repo_license::ActiveModel { name: Set(name.clone()), display_name: Set(display_name), description: Set(description), body: Set(body.unwrap_or_else(|| format!("License: {name}"))), url: Set(url), created_at: Set(now), updated_at: Set(now), }; am.insert(txn).await?; } let verified = repo_license::Entity::find_by_id(name.clone()) .one(txn) .await? .ok_or_else(|| { SyncError::Internal(format!( "license `{name}` missing after upsert for repo {}", repo.id )) })?; if verified.name != name { return Err(SyncError::InvalidLicense(format!( "license row mismatch: expected `{name}`, got `{}`", verified.name ))); } Ok(()) } async fn read_license_body(repo_dir: &Path) -> SyncResult> { for candidate in LICENSE_FILES { let path = repo_dir.join(candidate); match tokio::fs::read(&path).await { Ok(bytes) => { let text = String::from_utf8_lossy(&bytes).to_string(); if !text.trim().is_empty() { return Ok(Some(truncate(&text, 64 * 1024))); } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => return Err(SyncError::Io(error)), } } Ok(None) } fn truncate(text: &str, limit: usize) -> String { if text.len() <= limit { return text.to_string(); } let mut end = limit; while end > 0 && !text.is_char_boundary(end) { end -= 1; } text[..end].to_string() } fn infer_display_name(name: &str, body: Option<&str>) -> String { if let Some(text) = body { for line in text.lines().take(20) { let trimmed = line.trim(); if trimmed.is_empty() { continue; } if trimmed.to_lowercase().contains("license") { let candidate = trimmed .trim_start_matches(|c: char| !c.is_alphanumeric()) .trim(); if candidate.len() >= 3 && candidate.len() <= 80 { return candidate.to_string(); } } } } match name.to_uppercase().as_str() { "MIT" => "MIT License", "APACHE-2.0" | "APACHE2" => "Apache License 2.0", "GPL-3.0" | "GPL3" => "GNU General Public License v3.0", "BSD-3-CLAUSE" => "BSD 3-Clause License", "BSD-2-CLAUSE" => "BSD 2-Clause License", "MPL-2.0" => "Mozilla Public License 2.0", "ISC" => "ISC License", "UNLICENSE" => "The Unlicense", other => other, } .to_string() } fn infer_description(name: &str, body: Option<&str>) -> Option { let needle = name.to_lowercase(); if let Some(text) = body { for line in text.lines().take(20) { let lower = line.to_lowercase(); if lower.contains(&needle) && lower.contains("license") && line.len() <= 200 { return Some(line.trim().to_string()); } } } None } fn infer_url(name: &str) -> Option { let key = name.to_uppercase(); let slug = match key.as_str() { "MIT" => "mit", "APACHE-2.0" | "APACHE2" => "apache-2.0", "GPL-3.0" | "GPL3" => "gpl-3.0", "GPL-2.0" | "GPL2" => "gpl-2.0", "BSD-3-CLAUSE" | "BSD-2-CLAUSE" => "bsd", "MPL-2.0" => "mpl-2.0", "ISC" => "isc", "UNLICENSE" => "unlicense", _ => return None, }; Some(format!("https://spdx.org/licenses/{slug}.html")) }