upstream-rs 2.6.0

Fetch package updates directly from the source.
Documentation
use anyhow::{Context, Result};
use reqwest::{Client, header};
use serde::Deserialize;
use std::path::Path;

use crate::{
    models::upstream::DownloadConfig,
    providers::{download_handler, http::http_status},
};

use super::gitlab_dtos::GitlabReleaseDto;
#[derive(Debug, Deserialize)]
struct GitlabCommitRefDto {
    id: String,
}

#[derive(Debug, Deserialize)]
struct GitlabBranchDto {
    commit: GitlabCommitRefDto,
}

#[derive(Debug, Deserialize)]
struct GitlabProjectDto {
    default_branch: Option<String>,
}

#[derive(Debug, Clone)]
pub struct GitlabClient {
    client: Client,
    base_url: String,
    download_config: DownloadConfig,
}

impl GitlabClient {
    pub fn new(
        token: Option<&str>,
        base_url: Option<&str>,
        download_config: DownloadConfig,
    ) -> Result<Self> {
        let mut base = base_url.unwrap_or("https://gitlab.com").to_string();

        if !base.starts_with("http://") && !base.starts_with("https://") {
            base = format!("https://{}", base);
        }

        let mut headers = header::HeaderMap::new();
        let user_agent = format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
        headers.insert(
            header::USER_AGENT,
            header::HeaderValue::from_str(&user_agent)
                .context("Failed to create user agent header")?,
        );

        if let Some(token) = token {
            headers.insert(
                "PRIVATE-TOKEN",
                header::HeaderValue::from_str(token)
                    .context("Failed to create private token header")?,
            );
        }

        let client = Client::builder()
            .default_headers(headers)
            .build()
            .context("Failed to build HTTP client")?;

        Ok(Self {
            client,
            base_url: base,
            download_config,
        })
    }

    async fn get_json<T: for<'de> Deserialize<'de>>(&self, url: &str) -> Result<T> {
        let response = self
            .client
            .get(url)
            .send()
            .await
            .context(format!("Failed to send request to {}", url))?;

        http_status::error_for_status(&response, "GitLab API", url)?;

        let data = response
            .json::<T>()
            .await
            .context("Failed to parse JSON response")?;

        Ok(data)
    }

    async fn get_text(&self, url: &str) -> Result<String> {
        let response = self
            .client
            .get(url)
            .send()
            .await
            .context(format!("Failed to send request to {}", url))?;

        http_status::error_for_status(&response, "GitLab API", url)?;

        response
            .text()
            .await
            .context("Failed to read text response")
    }

    pub async fn download_file<F>(
        &self,
        url: &str,
        destination: &Path,
        progress: &mut Option<F>,
    ) -> Result<()>
    where
        F: FnMut(u64, u64),
    {
        download_handler::download_file(
            &self.client,
            url,
            destination,
            progress,
            self.download_config,
        )
        .await
    }

    pub async fn check_token(&self) -> Result<reqwest::Response> {
        let url = format!("{}/api/v4/user", self.base_url);
        self.client
            .get(&url)
            .send()
            .await
            .context(format!("Failed to send request to {}", url))
    }

    fn encode_project_path(project_path: &str) -> String {
        project_path.replace('/', "%2F")
    }

    pub async fn get_release_by_tag(
        &self,
        project_path: &str,
        tag: &str,
    ) -> Result<GitlabReleaseDto> {
        let encoded_path = Self::encode_project_path(project_path);
        let url = format!(
            "{}/api/v4/projects/{}/releases/{}",
            self.base_url, encoded_path, tag
        );
        self.get_json(&url)
            .await
            .context(format!("Failed to get release for tag {}", tag))
    }

    pub async fn get_releases(
        &self,
        project_path: &str,
        per_page: Option<u32>,
        max_total: Option<u32>,
    ) -> Result<Vec<GitlabReleaseDto>> {
        let per_page = per_page.unwrap_or(20).min(100);
        let mut page = 1;
        let mut releases = Vec::new();

        loop {
            let batch = self
                .get_releases_page(project_path, per_page, page)
                .await
                .context(format!("Failed to get releases page {}", page))?;
            let partial_page = batch.len() < per_page as usize;

            if batch.is_empty() {
                break;
            }

            releases.extend(batch);

            if let Some(max) = max_total
                && releases.len() >= max as usize
            {
                releases.truncate(max as usize);
                break;
            }

            if partial_page {
                break;
            }

            page += 1;
        }

        Ok(releases)
    }

    pub async fn get_releases_page(
        &self,
        project_path: &str,
        per_page: u32,
        page: u32,
    ) -> Result<Vec<GitlabReleaseDto>> {
        let encoded_path = Self::encode_project_path(project_path);
        let url = format!(
            "{}/api/v4/projects/{}/releases?per_page={}&page={}",
            self.base_url, encoded_path, per_page, page
        );
        self.get_json(&url)
            .await
            .context(format!("Failed to get releases page {}", page))
    }

    pub async fn get_branch_head_sha(&self, project_path: &str, branch: &str) -> Result<String> {
        let encoded_path = Self::encode_project_path(project_path);
        let encoded_branch = Self::encode_project_path(branch);
        let url = format!(
            "{}/api/v4/projects/{}/repository/branches/{}",
            self.base_url, encoded_path, encoded_branch
        );
        let dto: GitlabBranchDto = self.get_json(&url).await.context(format!(
            "Failed to get branch head for {}/{}",
            project_path, branch
        ))?;
        Ok(dto.commit.id)
    }

    pub async fn get_project_readme(&self, project_path: &str) -> Result<String> {
        let encoded_path = Self::encode_project_path(project_path);
        let project_url = format!("{}/api/v4/projects/{}", self.base_url, encoded_path);
        let project: GitlabProjectDto = self.get_json(&project_url).await.context(format!(
            "Failed to get project metadata for {}",
            project_path
        ))?;
        let branch = project
            .default_branch
            .as_deref()
            .filter(|branch| !branch.trim().is_empty())
            .unwrap_or("main");
        let encoded_branch = Self::encode_project_path(branch);
        let url = format!(
            "{}/api/v4/projects/{}/repository/files/README.md/raw?ref={}",
            self.base_url, encoded_path, encoded_branch
        );
        self.get_text(&url)
            .await
            .context(format!("Failed to get README for {}", project_path))
    }
}

#[cfg(test)]
mod tests {
    use super::GitlabClient;
    use crate::providers::gitlab::gitlab_dtos::GitlabReleaseDto;

    #[test]
    fn new_normalizes_base_url_without_scheme() {
        let client = GitlabClient::new(None, Some("gitlab.example.com"), Default::default())
            .expect("client");
        assert_eq!(client.base_url, "https://gitlab.example.com");
    }

    #[test]
    fn encode_project_path_percent_encodes_slashes() {
        assert_eq!(
            GitlabClient::encode_project_path("group/subgroup/project"),
            "group%2Fsubgroup%2Fproject"
        );
    }

    #[test]
    fn gitlab_release_dto_deserializes_minimal_valid_payload() {
        let json = r#"
            {
              "tag_name": "v1.0.0",
              "name": "v1.0.0",
              "description": "notes",
              "created_at": "2026-02-21T00:00:00Z",
              "released_at": null,
              "upcoming_release": false,
              "assets": { "count": 0, "sources": [], "links": [] }
            }
            "#;

        let parsed = serde_json::from_str::<GitlabReleaseDto>(json).expect("parse release");
        assert_eq!(parsed.tag_name, "v1.0.0");
        assert_eq!(parsed.assets.count, 0);
        assert!(parsed.assets.links.is_empty());
    }
}