upstream-rs 1.16.2

Fetch package updates directly from the source.
Documentation
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result, anyhow};
use chrono::Utc;

use crate::models::{
    common::enums::{Channel, Provider},
    provider::{Asset, Release},
};
use crate::providers::provider_manager::ProviderManager;
use crate::services::integration::compression_handler;

pub struct SourceDownload {
    pub workspace_path: PathBuf,
    pub release: Release,
    pub branch: Option<String>,
    pub commit: Option<String>,
}

pub struct SourceDownloader<'a> {
    provider_manager: &'a ProviderManager,
    cache_dir: PathBuf,
}

impl<'a> SourceDownloader<'a> {
    pub fn new(provider_manager: &'a ProviderManager) -> Result<Self> {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        let cache_dir = std::env::temp_dir().join(format!("upstream-build-{nonce}"));
        std::fs::create_dir_all(&cache_dir).context(format!(
            "Failed to create build cache '{}'",
            cache_dir.display()
        ))?;

        Ok(Self {
            provider_manager,
            cache_dir,
        })
    }

    pub async fn fetch_source(
        &self,
        repo_slug: &str,
        provider: &Provider,
        base_url: Option<&str>,
        channel: &Channel,
        tag: Option<&str>,
        branch: Option<&str>,
    ) -> Result<SourceDownload> {
        if branch.is_some() && tag.is_some() {
            return Err(anyhow!(
                "Build options --tag and --branch are mutually exclusive"
            ));
        }

        if let Some(branch_name) = branch {
            let head_commit = self
                .provider_manager
                .get_branch_head_sha(repo_slug, provider, branch_name, base_url)
                .await
                .context(format!(
                    "Failed to fetch branch head for '{}' on '{}'",
                    branch_name, repo_slug
                ))?;
            let branch_archive =
                self.make_source_archive_asset(repo_slug, provider, branch_name, base_url)?;

            let mut no_progress: Option<fn(u64, u64)> = None;
            let downloaded = self
                .provider_manager
                .download_asset(&branch_archive, provider, &self.cache_dir, &mut no_progress)
                .await
                .context(format!(
                    "Failed to download branch source archive '{}' for '{}'",
                    branch_name, repo_slug
                ))?;

            let extract_root = self.cache_dir.join("extract");
            std::fs::create_dir_all(&extract_root).context(format!(
                "Failed to create extraction root '{}'",
                extract_root.display()
            ))?;

            let extracted_path = compression_handler::decompress(&downloaded, &extract_root)
                .context("Failed to unpack source archive")?;
            let workspace_path = Self::resolve_workspace_root(&extracted_path)?;

            let release = Release {
                id: 0,
                tag: branch_name.to_string(),
                name: format!("branch {}", branch_name),
                body: String::new(),
                is_draft: false,
                is_prerelease: false,
                published_at: Utc::now(),
                assets: vec![],
                version: crate::models::common::version::Version::new(0, 0, 0, false),
            };

            return Ok(SourceDownload {
                workspace_path,
                release,
                branch: Some(branch_name.to_string()),
                commit: Some(head_commit),
            });
        }

        let release = if let Some(tag_name) = tag {
            self.provider_manager
                .get_release_by_tag(repo_slug, tag_name, provider, base_url)
                .await
                .context(format!(
                    "Failed to fetch release '{}' for '{}'",
                    tag_name, repo_slug
                ))?
        } else {
            self.provider_manager
                .get_latest_release(repo_slug, provider, channel, base_url)
                .await
                .context(format!(
                    "Failed to fetch latest release for '{}'",
                    repo_slug
                ))?
        };

        let primary_archive =
            self.make_source_archive_asset(repo_slug, provider, &release.tag, base_url)?;
        let mut no_progress: Option<fn(u64, u64)> = None;
        let downloaded_primary = self
            .provider_manager
            .download_asset(
                &primary_archive,
                provider,
                &self.cache_dir,
                &mut no_progress,
            )
            .await
            .context(format!(
                "Failed to download source archive for '{}'",
                repo_slug
            ));

        let downloaded = match downloaded_primary {
            Ok(path) => path,
            Err(primary_err) => {
                if let Some(fallback) = Self::find_release_source_asset(&release) {
                    self.provider_manager
                        .download_asset(fallback, provider, &self.cache_dir, &mut no_progress)
                        .await
                        .context(format!(
                            "Failed source download for '{}' using provider endpoint and release source asset fallback: {}",
                            repo_slug, primary_err
                        ))?
                } else {
                    return Err(primary_err);
                }
            }
        };

        let extract_root = self.cache_dir.join("extract");
        std::fs::create_dir_all(&extract_root).context(format!(
            "Failed to create extraction root '{}'",
            extract_root.display()
        ))?;

        let extracted_path = compression_handler::decompress(&downloaded, &extract_root)
            .context("Failed to unpack source archive")?;
        let workspace_path = Self::resolve_workspace_root(&extracted_path)?;

        Ok(SourceDownload {
            workspace_path,
            release,
            branch: None,
            commit: None,
        })
    }

    fn resolve_workspace_root(extracted_path: &Path) -> Result<PathBuf> {
        if Self::is_build_root(extracted_path) {
            return Ok(extracted_path.to_path_buf());
        }

        let mut candidates = Vec::new();
        let entries = std::fs::read_dir(extracted_path).context(format!(
            "Failed to scan extracted source root '{}'",
            extracted_path.display()
        ))?;

        for entry in entries {
            let entry = entry?;
            let path = entry.path();
            if path.is_dir() && Self::is_build_root(&path) {
                candidates.push(path);
            }
        }

        match candidates.len() {
            0 => Ok(extracted_path.to_path_buf()),
            1 => Ok(candidates.remove(0)),
            _ => {
                candidates.sort();
                let listed = candidates
                    .iter()
                    .map(|p| p.display().to_string())
                    .collect::<Vec<_>>()
                    .join(", ");
                Err(anyhow!(
                    "Build source root is ambiguous under '{}': found multiple candidate repositories [{}]",
                    extracted_path.display(),
                    listed
                ))
            }
        }
    }

    fn is_build_root(path: &Path) -> bool {
        if path.join("Cargo.toml").is_file() {
            return true;
        }
        if path.join("go.mod").is_file() {
            return true;
        }
        if path.join("build.zig").is_file() {
            return true;
        }
        if path.join("CMakeLists.txt").is_file() {
            return true;
        }

        std::fs::read_dir(path).ok().is_some_and(|entries| {
            entries.flatten().any(|entry| {
                entry.path().extension().is_some_and(|ext| {
                    let ext = ext.to_string_lossy();
                    ext.eq_ignore_ascii_case("sln") || ext.eq_ignore_ascii_case("csproj")
                })
            })
        })
    }

    fn make_source_archive_asset(
        &self,
        repo_slug: &str,
        provider: &Provider,
        git_ref: &str,
        base_url: Option<&str>,
    ) -> Result<Asset> {
        let url = match provider {
            Provider::Github => format!(
                "https://api.github.com/repos/{}/tarball/{}",
                repo_slug, git_ref
            ),
            Provider::Gitlab => {
                let base = base_url.unwrap_or("https://gitlab.com");
                let encoded = repo_slug.replace('/', "%2F");
                format!(
                    "{}/api/v4/projects/{}/repository/archive.tar.gz?sha={}",
                    base, encoded, git_ref
                )
            }
            Provider::Gitea => {
                let base = base_url.unwrap_or("https://gitea.com");
                format!(
                    "{}/api/v1/repos/{}/archive/{}.tar.gz",
                    base, repo_slug, git_ref
                )
            }
            Provider::Direct | Provider::WebScraper => {
                return Err(anyhow!(
                    "Build supports forge providers only (github/gitlab/gitea)"
                ));
            }
        };

        let asset_name = format!("{}-{}.tar.gz", repo_slug.replace('/', "-"), git_ref);
        Ok(Asset::new(url, 0, asset_name, 0, Utc::now()))
    }

    fn find_release_source_asset(release: &Release) -> Option<&Asset> {
        release
            .assets
            .iter()
            .find(|asset| asset.name.starts_with("source."))
    }
}

impl<'a> Drop for SourceDownloader<'a> {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.cache_dir);
    }
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::{Path, PathBuf};
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::SourceDownloader;

    fn temp_root(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        std::env::temp_dir().join(format!("upstream-downloader-test-{name}-{nanos}"))
    }

    fn fixture_path(relative: &str) -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests")
            .join("fixtures")
            .join(relative)
    }

    fn copy_fixture_dir(src: &Path, dst: &Path) {
        fs::create_dir_all(dst).expect("create fixture destination");
        for entry in fs::read_dir(src).expect("read fixture directory") {
            let entry = entry.expect("read fixture entry");
            let src_path = entry.path();
            let dst_path = dst.join(entry.file_name());
            if src_path.is_dir() {
                copy_fixture_dir(&src_path, &dst_path);
            } else {
                fs::copy(&src_path, &dst_path).expect("copy fixture file");
            }
        }
    }

    #[test]
    fn resolve_workspace_root_uses_root_when_manifest_exists() {
        let root = fixture_path("builder/workspace-roots/rust-single");

        let resolved = SourceDownloader::resolve_workspace_root(&root).expect("resolve root");
        assert_eq!(resolved, root);
    }

    #[test]
    fn resolve_workspace_root_selects_single_child_repo() {
        let root = temp_root("single-child");
        copy_fixture_dir(&fixture_path("builder/workspace-roots/pax-noise"), &root);
        let child = root.join("child");

        let resolved = SourceDownloader::resolve_workspace_root(&root).expect("resolve child root");
        assert_eq!(resolved, child);
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn resolve_workspace_root_uses_go_root_when_manifest_exists() {
        let root = fixture_path("builder/workspace-roots/go-single");

        let resolved = SourceDownloader::resolve_workspace_root(&root).expect("resolve root");
        assert_eq!(resolved, root);
    }

    #[test]
    fn resolve_workspace_root_uses_zig_root_when_manifest_exists() {
        let root = fixture_path("builder/workspace-roots/zig-single");

        let resolved = SourceDownloader::resolve_workspace_root(&root).expect("resolve root");
        assert_eq!(resolved, root);
    }

    #[test]
    fn resolve_workspace_root_uses_cmake_root_when_manifest_exists() {
        let root = fixture_path("builder/workspace-roots/cmake-single");

        let resolved = SourceDownloader::resolve_workspace_root(&root).expect("resolve root");
        assert_eq!(resolved, root);
    }

    #[test]
    fn resolve_workspace_root_errors_on_ambiguous_children() {
        let root = temp_root("ambiguous");
        copy_fixture_dir(
            &fixture_path("builder/workspace-roots/ambiguous-multi"),
            &root,
        );

        let err = SourceDownloader::resolve_workspace_root(&root).expect_err("must be ambiguous");
        assert!(err.to_string().contains("ambiguous"));
        let _ = fs::remove_dir_all(&root);
    }

    #[test]
    fn resolve_workspace_root_returns_input_when_no_candidates_exist() {
        let root = temp_root("no-candidates");
        fs::create_dir_all(&root).expect("create root");
        fs::write(root.join("README.md"), "hello").expect("write readme");

        let resolved = SourceDownloader::resolve_workspace_root(&root).expect("resolve fallback");
        assert_eq!(resolved, root);
        let _ = fs::remove_dir_all(&resolved);
    }
}