149bec6
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?;
tracing::debug!(
repo_id = %repo.id,
license = %name,
body_found = body.is_some(),
"upserting license record"
);
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<Option<String>> {
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) => {
tracing::warn!(path = %path.display(), error = %error, "failed to read license file");
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<String> {
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<String> {
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"))
}