Skip to main content

gitkraft_core/features/log/
ops.rs

1//! Log browsing and commit search operations.
2
3use anyhow::{Context, Result};
4use git2::Repository;
5
6use crate::features::commits::CommitInfo;
7
8/// Retrieve the commit log, optionally filtered by author and/or message substring.
9///
10/// Walks from HEAD, returning at most `max_count` commits that match every
11/// supplied filter (filters are AND-ed together).
12pub fn get_log(
13    repo: &Repository,
14    max_count: usize,
15    filter_author: Option<&str>,
16    filter_message: Option<&str>,
17) -> Result<Vec<CommitInfo>> {
18    let mut revwalk = repo.revwalk().context("failed to create revwalk")?;
19    revwalk.push_head().context("failed to push HEAD")?;
20    revwalk
21        .set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)
22        .context("failed to set sorting")?;
23
24    let author_lower = filter_author.map(|s| s.to_lowercase());
25    let message_lower = filter_message.map(|s| s.to_lowercase());
26
27    let mut results = Vec::with_capacity(max_count.min(256));
28
29    for oid_result in revwalk {
30        if results.len() >= max_count {
31            break;
32        }
33
34        let oid = oid_result.context("revwalk iteration error")?;
35        let commit = repo
36            .find_commit(oid)
37            .context("failed to find commit during log walk")?;
38
39        // ── Apply author filter ───────────────────────────────────────────
40        if let Some(ref needle) = author_lower {
41            let author = commit.author();
42            let name = author.name().unwrap_or("").to_lowercase();
43            let email = author.email().unwrap_or("").to_lowercase();
44            if !name.contains(needle.as_str()) && !email.contains(needle.as_str()) {
45                continue;
46            }
47        }
48
49        // ── Apply message filter ──────────────────────────────────────────
50        if let Some(ref needle) = message_lower {
51            let msg = commit.message().unwrap_or("").to_lowercase();
52            if !msg.contains(needle.as_str()) {
53                continue;
54            }
55        }
56
57        results.push(CommitInfo::from_git2_commit(&commit));
58    }
59
60    Ok(results)
61}
62
63/// Free-text search across commit summary, full message, author name, author
64/// email, and OID.
65///
66/// Returns at most `max_count` commits where `query` appears (case-insensitive)
67/// in any of those fields.
68pub fn search_commits(repo: &Repository, query: &str, max_count: usize) -> Result<Vec<CommitInfo>> {
69    let needle = query.to_lowercase();
70
71    let mut revwalk = repo.revwalk().context("failed to create revwalk")?;
72    revwalk.push_head().context("failed to push HEAD")?;
73    revwalk
74        .set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)
75        .context("failed to set sorting")?;
76
77    let mut results = Vec::with_capacity(max_count.min(256));
78
79    for oid_result in revwalk {
80        if results.len() >= max_count {
81            break;
82        }
83
84        let oid = oid_result.context("revwalk iteration error")?;
85        let commit = repo
86            .find_commit(oid)
87            .context("failed to find commit during search")?;
88
89        let summary = commit.summary().unwrap_or("").to_lowercase();
90        let message = commit.message().unwrap_or("").to_lowercase();
91        let author = commit.author();
92        let author_name = author.name().unwrap_or("").to_lowercase();
93        let author_email = author.email().unwrap_or("").to_lowercase();
94        let oid_str = oid.to_string();
95
96        if summary.contains(&needle)
97            || message.contains(&needle)
98            || author_name.contains(&needle)
99            || author_email.contains(&needle)
100            || oid_str.contains(&needle)
101        {
102            results.push(CommitInfo::from_git2_commit(&commit));
103        }
104    }
105
106    Ok(results)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use std::path::Path;
113
114    fn setup_repo_with_commits() -> (tempfile::TempDir, Repository) {
115        let dir = tempfile::tempdir().unwrap();
116        let repo = Repository::init(dir.path()).unwrap();
117
118        {
119            let mut config = repo.config().unwrap();
120            config.set_str("user.name", "Alice Test").unwrap();
121            config.set_str("user.email", "alice@example.com").unwrap();
122        }
123
124        // First commit
125        let c1 = {
126            std::fs::write(dir.path().join("a.txt"), "aaa\n").unwrap();
127            let mut index = repo.index().unwrap();
128            index.add_path(Path::new("a.txt")).unwrap();
129            index.write().unwrap();
130            let tree_oid = index.write_tree().unwrap();
131            let tree = repo.find_tree(tree_oid).unwrap();
132            let sig = repo.signature().unwrap();
133            repo.commit(Some("HEAD"), &sig, &sig, "first commit", &tree, &[])
134                .unwrap()
135        };
136
137        // Second commit by a different "author"
138        {
139            let commit1 = repo.find_commit(c1).unwrap();
140            std::fs::write(dir.path().join("b.txt"), "bbb\n").unwrap();
141            let mut index2 = repo.index().unwrap();
142            index2.add_path(Path::new("b.txt")).unwrap();
143            index2.write().unwrap();
144            let tree_oid2 = index2.write_tree().unwrap();
145            let tree2 = repo.find_tree(tree_oid2).unwrap();
146            let sig2 = git2::Signature::now("Bob Builder", "bob@example.com").unwrap();
147            repo.commit(
148                Some("HEAD"),
149                &sig2,
150                &sig2,
151                "second commit by Bob",
152                &tree2,
153                &[&commit1],
154            )
155            .unwrap();
156        }
157
158        (dir, repo)
159    }
160
161    #[test]
162    fn get_log_no_filters() {
163        let (_dir, repo) = setup_repo_with_commits();
164        let log = get_log(&repo, 100, None, None).unwrap();
165        assert_eq!(log.len(), 2);
166        // Newest first
167        assert_eq!(log[0].summary, "second commit by Bob");
168        assert_eq!(log[1].summary, "first commit");
169    }
170
171    #[test]
172    fn get_log_filter_author() {
173        let (_dir, repo) = setup_repo_with_commits();
174        let log = get_log(&repo, 100, Some("bob"), None).unwrap();
175        assert_eq!(log.len(), 1);
176        assert_eq!(log[0].summary, "second commit by Bob");
177    }
178
179    #[test]
180    fn get_log_filter_message() {
181        let (_dir, repo) = setup_repo_with_commits();
182        let log = get_log(&repo, 100, None, Some("first")).unwrap();
183        assert_eq!(log.len(), 1);
184        assert_eq!(log[0].summary, "first commit");
185    }
186
187    #[test]
188    fn get_log_respects_max_count() {
189        let (_dir, repo) = setup_repo_with_commits();
190        let log = get_log(&repo, 1, None, None).unwrap();
191        assert_eq!(log.len(), 1);
192    }
193
194    #[test]
195    fn search_commits_by_author() {
196        let (_dir, repo) = setup_repo_with_commits();
197        let results = search_commits(&repo, "alice", 100).unwrap();
198        assert_eq!(results.len(), 1);
199        assert_eq!(results[0].summary, "first commit");
200    }
201
202    #[test]
203    fn search_commits_by_message() {
204        let (_dir, repo) = setup_repo_with_commits();
205        let results = search_commits(&repo, "second", 100).unwrap();
206        assert_eq!(results.len(), 1);
207        assert_eq!(results[0].author_name, "Bob Builder");
208    }
209
210    #[test]
211    fn search_commits_case_insensitive() {
212        let (_dir, repo) = setup_repo_with_commits();
213        let results = search_commits(&repo, "BOB", 100).unwrap();
214        assert_eq!(results.len(), 1);
215    }
216}