Skip to content
Clone
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"))
}