thesa 4.1.33

Archive GitHub repositories, ML models, datasets, and websites with Scrin/Aisling workflows
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};

use crate::cli::GitRefreshArgs;
use crate::constants::THESA_METADATA_DIR;
use crate::error::{Result, ThesaError};
use crate::manifest::{read_archive_manifest_sidecar, write_archive_manifest};

pub(crate) fn run_git_archive_refresh(args: &GitRefreshArgs, dry_run: bool) -> Result<()> {
    let owner = git_refresh_owner(args)?;
    let archives = if let Some(owner) = owner.as_deref() {
        repoforge::discover_git_archives_for_owner(
            &args.archive_root,
            owner,
            args.filter.as_deref(),
        )
        .map_err(repoforge_err)?
    } else {
        repoforge::discover_git_archives(&args.archive_root, args.filter.as_deref())
            .map_err(repoforge_err)?
    };

    if archives.is_empty() {
        if let Some(owner) = owner.as_deref() {
            println!(
                "No Git archives found for owner {owner} at {}",
                args.archive_root.display()
            );
        } else {
            println!("No Git archives found at {}", args.archive_root.display());
        }
        return Ok(());
    }

    if dry_run {
        println!(
            "Dry run: {} Git archives would be refreshed",
            archives.len()
        );
        println!("Archive root: {}", args.archive_root.display());
        if let Some(owner) = owner.as_deref() {
            println!("Owner: {owner}");
        }
        if let Some(filter) = &args.filter {
            println!("Filter: {filter}");
        }
        for archive in &archives {
            let remote_note = if archive.remote_url.is_some() {
                "origin"
            } else {
                "slug inferred from path"
            };
            println!(
                " - {} ({}) [{}]",
                archive.slug,
                archive.path.display(),
                remote_note
            );
        }
        return Ok(());
    }

    println!(
        "Refreshing {} Git archives under {}",
        archives.len(),
        args.archive_root.display()
    );
    if let Some(owner) = owner.as_deref() {
        println!("Owner: {owner}");
    }
    let summary = repoforge::refresh_git_archives(&archives, args.concurrency.max(1));
    let manifests =
        write_git_refresh_manifests(&args.archive_root, &archives, &summary.failed_slugs())?;
    let total = archives.len();

    for (index, entry) in summary.entries.iter().enumerate() {
        match entry.status {
            repoforge::GitRefreshStatus::Updated => println!(
                "[done] {} updated ({}/{}) {}",
                entry.slug,
                index + 1,
                total,
                entry.path.display()
            ),
            repoforge::GitRefreshStatus::Unchanged => println!(
                "[skip] {} up-to-date ({}/{}) {}",
                entry.slug,
                index + 1,
                total,
                entry.path.display()
            ),
        }
    }
    for failure in &summary.failed {
        eprintln!(
            "[fail] {} {}: {}",
            failure.slug,
            failure.path.display(),
            failure.message
        );
    }

    println!(
        "\nCompleted: {} updated, {} up-to-date, {} failed",
        summary.updated,
        summary.unchanged,
        summary.failed.len()
    );
    if !manifests.is_empty() {
        println!("Manifests refreshed:");
        for manifest in &manifests {
            println!(" - {}", manifest.display());
        }
    }

    if !summary.failed.is_empty() {
        eprintln!("Failures:");
        for failure in &summary.failed {
            eprintln!(" - {}: {}", failure.slug, failure.message);
        }
        return Err(ThesaError::Message(
            "some Git archives failed to refresh".to_string(),
        ));
    }

    Ok(())
}

pub(crate) fn git_refresh_owner(args: &GitRefreshArgs) -> Result<Option<String>> {
    let positional = args
        .owner
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty());
    let flag = args
        .owner_filter
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty());

    match (positional, flag) {
        (Some(left), Some(right)) if !left.eq_ignore_ascii_case(right) => Err(ThesaError::Message(
            format!("conflicting refresh owners: positional '{left}' and --owner '{right}'"),
        )),
        (Some(owner), _) | (_, Some(owner)) => Ok(Some(owner.to_string())),
        (None, None) => Ok(None),
    }
}

fn repoforge_err(err: repoforge::RepoForgeError) -> ThesaError {
    ThesaError::Message(format!("repoforge refresh failed: {err}"))
}

fn write_git_refresh_manifests(
    root: &Path,
    archives: &[repoforge::GitArchiveCandidate],
    failed_slugs: &BTreeSet<String>,
) -> Result<Vec<PathBuf>> {
    let mut groups: BTreeMap<PathBuf, Vec<repoforge::GitArchiveCandidate>> = BTreeMap::new();
    for archive in archives {
        groups
            .entry(git_archive_group_root(root, &archive.path))
            .or_default()
            .push(archive.clone());
    }

    let mut manifests = Vec::new();
    for (group_root, group_archives) in groups {
        let target = git_refresh_manifest_target(root, &group_root, &group_archives);
        let complete = group_archives
            .iter()
            .all(|archive| !failed_slugs.contains(&archive.slug));
        write_archive_manifest(&group_root, "github", &target, complete)?;
        manifests.push(group_root.join(THESA_METADATA_DIR).join("manifest.json"));
    }

    Ok(manifests)
}

fn git_archive_group_root(root: &Path, repo_path: &Path) -> PathBuf {
    if let Ok(relative) = repo_path.strip_prefix(root) {
        for component in relative.components() {
            if let Component::Normal(first) = component {
                return root.join(first);
            }
        }
    }
    repo_path.to_path_buf()
}

fn git_refresh_manifest_target(
    root: &Path,
    group_root: &Path,
    archives: &[repoforge::GitArchiveCandidate],
) -> String {
    if let Ok(manifest) = read_archive_manifest_sidecar(group_root) {
        if manifest.source.platform == "github" && !manifest.source.target.trim().is_empty() {
            return manifest.source.target;
        }
    }

    let group_name = group_root
        .strip_prefix(root)
        .ok()
        .and_then(|relative| relative.components().next())
        .and_then(|component| match component {
            Component::Normal(value) => value.to_str().map(ToString::to_string),
            _ => None,
        })
        .or_else(|| {
            group_root
                .file_name()
                .and_then(|value| value.to_str())
                .map(ToString::to_string)
        })
        .unwrap_or_else(|| "github".to_string());

    let owners = archives
        .iter()
        .filter_map(|archive| {
            archive
                .slug
                .split_once('/')
                .map(|(owner, _)| owner.to_string())
        })
        .collect::<BTreeSet<_>>();
    if owners.len() == 1 && owners.contains(&group_name) {
        return group_name;
    }
    if archives.len() == 1 {
        return archives[0].slug.clone();
    }
    group_name
}