travelagent-core 1.11.1

Core library for travelagent code review tool
Documentation
use chrono::{TimeZone, Utc};
use git2::{BranchType, Oid, Repository};
use std::collections::HashMap;

use crate::error::{Result, TrvError};
use crate::vcs::traits::CommitInfo;

/// Parse a full commit message into (summary, optional body).
/// The summary is the first line; the body is everything after the first blank line, trimmed.
fn parse_commit_message(message: &str) -> (String, Option<String>) {
    let mut lines = message.lines();
    let summary = lines.next().unwrap_or("(no message)").to_string();
    // Skip blank separator line(s) between summary and body
    let body_text: String = lines
        .skip_while(|l| l.trim().is_empty())
        .collect::<Vec<_>>()
        .join("\n");
    let body = if body_text.trim().is_empty() {
        None
    } else {
        Some(body_text)
    };
    (summary, body)
}

fn get_branch_tip_names(repo: &Repository) -> HashMap<Oid, Vec<String>> {
    let mut names_by_tip: HashMap<Oid, Vec<String>> = HashMap::new();

    if let Ok(branches) = repo.branches(Some(BranchType::Local)) {
        for (branch, _) in branches.flatten() {
            let Some(target) = branch.get().target() else {
                continue;
            };

            let Ok(Some(name)) = branch.name() else {
                continue;
            };

            names_by_tip
                .entry(target)
                .or_default()
                .push(name.to_string());
        }
    }

    for names in names_by_tip.values_mut() {
        names.sort_unstable();
    }

    names_by_tip
}

pub fn get_recent_commits(
    repo: &Repository,
    offset: usize,
    limit: usize,
) -> Result<Vec<CommitInfo>> {
    let mut revwalk = repo.revwalk()?;
    revwalk.push_head()?;
    let branch_tip_names = get_branch_tip_names(repo);

    let mut commits = Vec::new();
    for oid in revwalk.skip(offset).take(limit) {
        let oid = oid?;
        let commit = repo.find_commit(oid)?;

        let id = oid.to_string();
        let short_id = id[..7.min(id.len())].to_string();
        let full_message = commit.message().unwrap_or("(no message)");
        let (summary, body) = parse_commit_message(full_message);
        let author = commit.author().name().unwrap_or("Unknown").to_string();
        let branch_name = branch_tip_names
            .get(&oid)
            .and_then(|names| names.first().cloned());
        let time = Utc
            .timestamp_opt(commit.time().seconds(), 0)
            .single()
            .unwrap_or_else(Utc::now);

        commits.push(CommitInfo {
            id,
            short_id,
            branch_name,
            summary,
            body,
            author,
            time,
        });
    }

    Ok(commits)
}

/// Get commit info for specific commit IDs.
/// Returns CommitInfo in the same order as the input IDs.
pub fn get_commits_info(repo: &Repository, ids: &[String]) -> Result<Vec<CommitInfo>> {
    let branch_tip_names = get_branch_tip_names(repo);
    let mut commits = Vec::new();

    for id_str in ids {
        let oid = Oid::from_str(id_str)
            .map_err(|e| TrvError::VcsCommand(format!("Invalid commit ID {id_str}: {e}")))?;
        let commit = repo
            .find_commit(oid)
            .map_err(|e| TrvError::VcsCommand(format!("Commit not found {id_str}: {e}")))?;

        let id = oid.to_string();
        let short_id = id[..7.min(id.len())].to_string();
        let full_message = commit.message().unwrap_or("(no message)");
        let (summary, body) = parse_commit_message(full_message);
        let author = commit.author().name().unwrap_or("Unknown").to_string();
        let branch_name = branch_tip_names
            .get(&oid)
            .and_then(|names| names.first().cloned());
        let time = Utc
            .timestamp_opt(commit.time().seconds(), 0)
            .single()
            .unwrap_or_else(Utc::now);

        commits.push(CommitInfo {
            id,
            short_id,
            branch_name,
            summary,
            body,
            author,
            time,
        });
    }

    Ok(commits)
}

/// Resolve a git revision range expression to a list of commit IDs (oldest first).
///
/// Supports:
/// - Single revisions ("HEAD~3") — returns just that commit.
/// - Two-dot ranges ("A..B") — walks from B back, stopping before A.
/// - Three-dot ranges ("A...B") — walks from B back, stopping at the merge base of A and B.
pub fn resolve_revisions(repo: &Repository, revisions: &str) -> Result<Vec<String>> {
    // Try parsing as a range first (e.g., "A..B" or "A...B")
    let revspec = repo.revparse(revisions)?;
    let mode = revspec.mode();

    let mut commit_ids = if mode.contains(git2::RevparseMode::MERGE_BASE) {
        // Three-dot range "A...B": walk from `to` back, hiding the merge base of A and B.
        let from = revspec.from().ok_or_else(|| {
            TrvError::VcsCommand("Invalid three-dot range: missing 'from'".into())
        })?;
        let to = revspec
            .to()
            .ok_or_else(|| TrvError::VcsCommand("Invalid three-dot range: missing 'to'".into()))?;

        let merge_base = repo.merge_base(from.id(), to.id()).map_err(|_| {
            TrvError::UnsupportedOperation("Three-dot range not supported for this revset".into())
        })?;

        let mut revwalk = repo.revwalk()?;
        revwalk.push(to.id())?;
        revwalk.hide(merge_base)?;
        revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;

        let mut ids = Vec::new();
        for oid in revwalk {
            ids.push(oid?.to_string());
        }
        ids
    } else if mode.contains(git2::RevparseMode::RANGE) {
        // Two-dot range "A..B": walk from `to` back, stopping before `from`
        let from = revspec
            .from()
            .ok_or_else(|| TrvError::VcsCommand("Invalid revision range: missing 'from'".into()))?;
        let to = revspec
            .to()
            .ok_or_else(|| TrvError::VcsCommand("Invalid revision range: missing 'to'".into()))?;

        let mut revwalk = repo.revwalk()?;
        revwalk.push(to.id())?;
        revwalk.hide(from.id())?;
        revwalk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;

        let mut ids = Vec::new();
        for oid in revwalk {
            ids.push(oid?.to_string());
        }
        ids
    } else {
        // Single revision
        let obj = revspec
            .from()
            .ok_or_else(|| TrvError::VcsCommand("Invalid revision expression".into()))?;
        let commit = obj
            .peel_to_commit()
            .map_err(|e| TrvError::VcsCommand(format!("Not a commit: {e}")))?;
        vec![commit.id().to_string()]
    };

    if commit_ids.is_empty() {
        // For three-dot ranges, an empty result is valid (e.g., HEAD...HEAD has
        // merge_base == HEAD, so no commits to walk). Callers that need non-empty
        // should check separately.
        if mode.contains(git2::RevparseMode::MERGE_BASE) {
            return Ok(commit_ids);
        }
        return Err(TrvError::NoChanges);
    }

    // revwalk outputs newest first; reverse so oldest is first
    commit_ids.reverse();
    Ok(commit_ids)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::Path;

    fn create_commit(repo: &Repository, file_name: &str, content: &str, msg: &str) -> Oid {
        fs::write(repo.workdir().unwrap().join(file_name), content).expect("failed to write file");

        let mut index = repo.index().expect("failed to open index");
        index
            .add_path(Path::new(file_name))
            .expect("failed to add file to index");
        index.write().expect("failed to write index");

        let tree_id = index.write_tree().expect("failed to write tree");
        let tree = repo.find_tree(tree_id).expect("failed to find tree");
        let sig = git2::Signature::now("Test User", "test@example.com")
            .expect("failed to create signature");

        let parent_ref = repo.head().ok();
        let parents: Vec<git2::Commit> = parent_ref
            .and_then(|r| r.peel_to_commit().ok())
            .into_iter()
            .collect();
        let parent_refs: Vec<&git2::Commit> = parents.iter().collect();

        repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parent_refs)
            .expect("failed to create commit")
    }

    #[test]
    fn should_resolve_three_dot_range_head_to_head_as_empty() {
        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
        let repo = Repository::init(temp_dir.path()).expect("failed to init repo");
        create_commit(&repo, "file.txt", "hello\n", "initial");

        // HEAD...HEAD: merge base of HEAD with itself is HEAD; walking from HEAD
        // while hiding HEAD yields zero commits.
        let ids = resolve_revisions(&repo, "HEAD...HEAD").expect("three-dot resolve failed");
        assert!(
            ids.is_empty(),
            "expected empty list for HEAD...HEAD, got {ids:?}"
        );
    }

    #[test]
    fn should_resolve_three_dot_range_walks_to_merge_base() {
        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
        let repo = Repository::init(temp_dir.path()).expect("failed to init repo");

        // Create a base commit on main. `Repository::init` respects the user's
        // `init.defaultBranch` config, which on CI runners may be `master` —
        // force HEAD to `main` so the revspec below works regardless of host.
        repo.set_head("refs/heads/main")
            .expect("failed to set HEAD to main");
        let base_oid = create_commit(&repo, "base.txt", "base\n", "base");

        // Create a feature branch pointing at base, then add a commit on main
        repo.branch("feature", &repo.find_commit(base_oid).unwrap(), false)
            .expect("failed to create feature branch");

        let main_oid = create_commit(&repo, "main.txt", "main-only\n", "on main");

        // Switch to feature and add a commit there
        repo.set_head("refs/heads/feature")
            .expect("failed to set HEAD to feature");
        repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
            .expect("failed to checkout feature");
        let feature_oid = create_commit(&repo, "feature.txt", "feature-only\n", "on feature");

        // main...feature — the merge base is base_oid; walking feature back while
        // hiding base should yield only the feature commit.
        let ids = resolve_revisions(&repo, "main...feature").expect("three-dot resolve failed");
        assert_eq!(
            ids.len(),
            1,
            "expected only feature-side commit, got {ids:?}"
        );
        assert_eq!(ids[0], feature_oid.to_string());
        // Sanity: main's unique commit must NOT be in the result.
        assert!(!ids.contains(&main_oid.to_string()));
    }

    #[test]
    fn should_resolve_two_dot_range() {
        let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
        let repo = Repository::init(temp_dir.path()).expect("failed to init repo");

        let c1 = create_commit(&repo, "a.txt", "1\n", "c1");
        let c2 = create_commit(&repo, "a.txt", "2\n", "c2");
        let c3 = create_commit(&repo, "a.txt", "3\n", "c3");

        // c1..HEAD should return c2 and c3 in oldest→newest order
        let range = format!("{c1}..HEAD");
        let ids = resolve_revisions(&repo, &range).expect("two-dot resolve failed");
        assert_eq!(ids, vec![c2.to_string(), c3.to_string()]);
    }
}