Abbaye

at e1dfdc3

use chrono::{DateTime, Utc};
use miette::{IntoDiagnostic, Result, miette};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use super::{VersionExtractor, VersionInfo};

fn default_dirty_suffix() -> String {
    "-dirty".to_owned()
}

/// Configuration for [`GitVersion`].
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct GitVersionConfig {
    /// Strip this prefix from the tag name before returning the version.
    /// For example, `"v"` turns `"v1.2.3"` into `"1.2.3"`.
    pub tag_prefix: Option<String>,

    /// Suffix appended to the version when the working tree has uncommitted
    /// changes. Forwarded verbatim as `--dirty=<suffix>` to `git describe`.
    /// Defaults to `"-dirty"`.
    #[serde(default = "default_dirty_suffix")]
    pub dirty_suffix: String,
}

impl Default for GitVersionConfig {
    fn default() -> Self {
        Self {
            tag_prefix: None,
            dirty_suffix: default_dirty_suffix(),
        }
    }
}

/// Extracts the version by running `git describe --tags --always`.
pub struct GitVersion;

/// Run `git log -1 --format=%cI <refspec>` and parse the result as a UTC
/// [`DateTime`].  Returns `None` on any failure so callers can treat the date
/// as optional.
async fn git_commit_date(refspec: &str) -> Option<DateTime<Utc>> {
    let output = tokio::process::Command::new("git")
        .args(["log", "-1", "--format=%cI", refspec])
        .output()
        .await
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let raw = String::from_utf8(output.stdout).ok()?;
    DateTime::parse_from_rfc3339(raw.trim())
        .ok()
        .map(|dt| dt.with_timezone(&Utc))
}

impl VersionExtractor for GitVersion {
    type ConfigType = GitVersionConfig;

    async fn get_last_version(&self, config: Self::ConfigType) -> Result<VersionInfo> {
        let dirty_arg = format!("--dirty={}", config.dirty_suffix);

        let output = tokio::process::Command::new("git")
            .args(["describe", "--tags", "--always", &dirty_arg])
            .output()
            .await
            .into_diagnostic()?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(miette!("git describe failed:\n{stderr}"));
        }

        let raw = String::from_utf8(output.stdout).into_diagnostic()?;
        let version_raw = raw.trim();

        let version = match &config.tag_prefix {
            Some(prefix) => version_raw
                .strip_prefix(prefix.as_str())
                .unwrap_or(version_raw),
            None => version_raw,
        }
        .to_owned();

        // Use the HEAD commit date as the "release date" for the current build.
        let date = git_commit_date("HEAD").await;

        Ok(VersionInfo { version, date })
    }

    /// Return all Git tags as [`VersionInfo`] entries, each carrying the
    /// tag's creator date (annotated tag date, or commit date for lightweight
    /// tags).  Uses a single `git for-each-ref` invocation.
    async fn get_all_versions(&self, config: Self::ConfigType) -> Result<Vec<VersionInfo>> {
        // %(creatordate:iso-strict) works for both annotated and lightweight tags.
        let output = tokio::process::Command::new("git")
            .args([
                "for-each-ref",
                "--sort=version:refname",
                "--format=%(refname:short)\t%(creatordate:iso-strict)",
                "refs/tags",
            ])
            .output()
            .await
            .into_diagnostic()?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(miette!("git for-each-ref failed:\n{stderr}"));
        }

        let raw = String::from_utf8(output.stdout).into_diagnostic()?;

        let versions = raw
            .lines()
            .filter(|s| !s.is_empty())
            .map(|line| {
                // Split on the first tab; anything after is the date string.
                let (tag, date_str) = line.split_once('\t').unwrap_or((line, ""));

                let version = match &config.tag_prefix {
                    Some(prefix) => tag.strip_prefix(prefix.as_str()).unwrap_or(tag),
                    None => tag,
                }
                .to_owned();

                let date = DateTime::parse_from_rfc3339(date_str.trim())
                    .ok()
                    .map(|dt| dt.with_timezone(&Utc));

                VersionInfo { version, date }
            })
            .collect();

        Ok(versions)
    }
}