convco 0.7.0

Conventional commit tools
use std::{borrow::Cow, collections::HashSet};

use bstr::ByteSlice;
use git2::{Delta, Pathspec, PathspecFlags};
use jiff::{
    tz::{Offset, TimeZone},
    Timestamp,
};

use super::{Commit, CommitTrait, Repo, RevWalkIter, RevWalkOptions};
use crate::error::ConvcoError;

impl CommitTrait for git2::Commit<'_> {
    type ObjectId = git2::Oid;

    fn short_id(&self) -> String {
        self.as_object()
            .short_id()
            .unwrap()
            .as_str()
            .unwrap()
            .to_owned()
    }

    fn commit_message(&self) -> Result<Cow<'_, str>, ConvcoError> {
        Ok(self.message_bytes().to_str_lossy())
    }

    fn id(&self) -> String {
        self.id().to_string()
    }

    fn oid(&self) -> Self::ObjectId {
        self.id()
    }

    fn commit_time(&self) -> Result<jiff::Zoned, ConvcoError> {
        let time = self.time();
        let unix_time = time.seconds();
        let offset = time.offset_minutes();
        let timestamp = Timestamp::from_second(unix_time)?;
        let tz = TimeZone::fixed(Offset::from_seconds(offset * 60)?);

        Ok(timestamp.to_zoned(tz))
    }
}

impl<'repo> Repo<'repo> for git2::Repository {
    type CommitTrait = git2::Commit<'repo>;

    fn open() -> Result<Self, ConvcoError> {
        Ok(git2::Repository::open_from_env()?)
    }

    fn url(&self, remote: &str) -> Result<Option<String>, ConvcoError> {
        match self.find_remote(remote) {
            Ok(remote) => Ok(Some(remote.url()?.to_owned())),
            Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
            Err(err) => Err(err.into()),
        }
    }

    fn find_last_version(
        &'repo self,
        commit: &Self::CommitTrait,
        ignore_prereleases: bool,
        semvers: &[(semver::Version, Self::CommitTrait)],
    ) -> Result<Option<(semver::Version, Self::CommitTrait)>, ConvcoError> {
        let mut revwalk = self.revwalk()?;
        revwalk.push(commit.id())?;
        let reachable = revwalk.flatten().collect::<HashSet<_>>();

        Ok(semvers
            .iter()
            .filter(|(version, _)| !ignore_prereleases || version.pre.is_empty())
            .find(|(_, commit)| reachable.contains(&commit.id()))
            .map(|(version, commit)| (version.clone(), commit.clone())))
    }

    fn revwalk(
        &'repo self,
        options: RevWalkOptions<'repo, Self::CommitTrait>,
    ) -> Result<RevWalkIter<'repo, Self::CommitTrait>, ConvcoError> {
        let commit = options.to_rev;
        let mut revwalk = self.revwalk()?;
        if options.first_parent {
            revwalk.simplify_first_parent()?;
        }
        for rev in options.from_rev {
            revwalk.hide(rev.id())?;
        }
        revwalk.push(commit.id())?;

        let mut revwalk: Box<dyn Iterator<Item = _>> =
            Box::new(revwalk.flatten().flat_map(|i| self.find_commit(i)));
        if options.no_merge_commits {
            revwalk = Box::new(revwalk.filter(move |commit| commit.parent_count() <= 1));
        }
        if !options.paths.is_empty() {
            let pathspec_filter = Git2PathspecFilter::new(options.paths.as_slice());
            revwalk = Box::new(revwalk.filter(move |commit| {
                pathspec_filter
                    .as_ref()
                    .is_some_and(|filter| self.commit_changes_path(commit, filter))
            }));
        }
        let revwalk: Box<dyn Iterator<Item = _>> = Box::new(revwalk.filter_map(move |commit| {
            let message = commit.message().ok().map(ToOwned::to_owned);
            message.and_then(|msg| {
                if options.no_revert_commits && msg.starts_with("Revert \"") {
                    return None;
                }
                Some(match options.parser.parse(&msg) {
                    Ok(conventional_commit) => Ok(Commit {
                        conventional_commit,
                        commit,
                    }),
                    Err(e) => Err((e.into(), commit)),
                })
            })
        }));

        Ok(revwalk)
    }

    fn semver_tags(
        &'repo self,
        prefix: &str,
    ) -> Result<Vec<(semver::Version, Self::CommitTrait)>, ConvcoError> {
        let mut versions = self
            .references_glob(&format!("refs/tags/{prefix}*"))?
            .flatten()
            .filter_map(|tag| {
                let name = tag.shorthand_bytes();
                let name = name.strip_prefix(prefix.as_bytes()).unwrap();
                name.to_str()
                    .ok()
                    .and_then(|name| semver::Version::parse(name).ok())
                    .and_then(|version| tag.peel_to_commit().ok().map(|commit| (version, commit)))
            })
            .collect::<Vec<_>>();
        versions.sort_by(|a, b| b.0.cmp(&a.0));
        Ok(versions)
    }

    fn revparse_single(&'repo self, spec: &str) -> Result<Self::CommitTrait, ConvcoError> {
        Ok(self.revparse_single(spec)?.peel_to_commit()?)
    }

    fn revision_time(
        &'repo self,
        spec: &str,
        commit: &Self::CommitTrait,
    ) -> Result<jiff::Zoned, ConvcoError> {
        let object = self.revparse_single(spec)?;
        if let Some(time) = object.as_tag().and_then(|tag| tag.tagger()).map(|tagger| {
            let time = tagger.when();
            (time.seconds(), time.offset_minutes() * 60)
        }) {
            zoned_from_git_time(time.0, time.1)
        } else {
            commit.commit_time()
        }
    }
}

fn zoned_from_git_time(seconds: i64, offset_seconds: i32) -> Result<jiff::Zoned, ConvcoError> {
    let timestamp = Timestamp::from_second(seconds)?;
    let tz = TimeZone::fixed(Offset::from_seconds(offset_seconds)?);

    Ok(timestamp.to_zoned(tz))
}

trait Git2Ext {
    fn commit_changes_path(&self, commit: &git2::Commit, filter: &Git2PathspecFilter) -> bool;
}

impl Git2Ext for git2::Repository {
    fn commit_changes_path(&self, commit: &git2::Commit, filter: &Git2PathspecFilter) -> bool {
        let new_tree = commit.tree().ok();
        let new_tree = new_tree.as_ref();

        let diff = if commit.parent_count() == 0 {
            let old_tree = None;
            self.diff_tree_to_tree(old_tree, new_tree, None)
        } else {
            let old_tree = commit.parent(0).and_then(|parent| parent.tree()).ok();
            let old_tree = old_tree.as_ref();
            self.diff_tree_to_tree(old_tree, new_tree, None)
        };

        let Ok(diff) = diff else {
            return false;
        };

        filter.matches(&diff)
    }
}

struct Git2PathspecFilter {
    include: Option<Pathspec>,
    exclude: Option<Pathspec>,
}

impl Git2PathspecFilter {
    fn new(paths: &[String]) -> Option<Self> {
        let include_paths = paths
            .iter()
            .filter(|path| !is_exclude_pathspec(path))
            .collect::<Vec<_>>();
        let exclude_paths = paths
            .iter()
            .filter_map(|path| positive_pathspec_from_exclude(path))
            .collect::<Vec<_>>();

        let include = if include_paths.is_empty() {
            None
        } else {
            Some(Pathspec::new(include_paths).ok()?)
        };
        let exclude = if exclude_paths.is_empty() {
            None
        } else {
            Some(Pathspec::new(&exclude_paths).ok()?)
        };

        (include.is_some() || exclude.is_some()).then_some(Self { include, exclude })
    }

    fn matches(&self, diff: &git2::Diff<'_>) -> bool {
        diff.deltas().any(|delta| {
            let old_path = (delta.status() != Delta::Added)
                .then(|| delta.old_file().path())
                .flatten();
            let new_path = (delta.status() != Delta::Deleted)
                .then(|| delta.new_file().path())
                .flatten();

            old_path.into_iter().chain(new_path).any(|path| {
                let included = self
                    .include
                    .as_ref()
                    .is_none_or(|pathspec| pathspec.matches_path(path, PathspecFlags::DEFAULT));
                let excluded = self
                    .exclude
                    .as_ref()
                    .is_some_and(|pathspec| pathspec.matches_path(path, PathspecFlags::DEFAULT));

                included && !excluded
            })
        })
    }
}

fn is_exclude_pathspec(pathspec: &str) -> bool {
    pathspec.starts_with(":!")
        || pathspec.starts_with(":^")
        || long_magic_contains_exclude(pathspec)
}

fn long_magic_contains_exclude(pathspec: &str) -> bool {
    let Some(magic) = pathspec
        .strip_prefix(":(")
        .and_then(|pathspec| pathspec.split_once(')').map(|(magic, _)| magic))
    else {
        return false;
    };

    magic.split(',').any(|token| token == "exclude")
}

fn positive_pathspec_from_exclude(pathspec: &str) -> Option<String> {
    if let Some(pathspec) = pathspec
        .strip_prefix(":!")
        .or_else(|| pathspec.strip_prefix(":^"))
    {
        return Some(pathspec.to_owned());
    }

    let pathspec = pathspec.strip_prefix(":(")?;
    let (magic, pattern) = pathspec.split_once(')')?;
    let magic = magic
        .split(',')
        .filter(|token| *token != "exclude")
        .collect::<Vec<_>>();

    Some(if magic.is_empty() {
        pattern.to_owned()
    } else {
        format!(":({}){}", magic.join(","), pattern)
    })
}