use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
use super::commit::CommitStore;
use super::types::{BranchError, ChangeType, Commit, FileChange};
#[derive(Debug, Clone)]
pub struct LogOptions {
pub limit: Option<usize>,
pub skip: usize,
pub path_filter: Option<String>,
pub author_filter: Option<String>,
pub since: Option<u64>,
pub until: Option<u64>,
pub show_changes: bool,
pub format: LogFormat,
}
impl Default for LogOptions {
fn default() -> Self {
Self {
limit: None,
skip: 0,
path_filter: None,
author_filter: None,
since: None,
until: None,
show_changes: false,
format: LogFormat::Medium,
}
}
}
impl LogOptions {
pub fn last(n: usize) -> Self {
Self {
limit: Some(n),
..Default::default()
}
}
pub fn with_limit(mut self, n: usize) -> Self {
self.limit = Some(n);
self
}
pub fn with_skip(mut self, n: usize) -> Self {
self.skip = n;
self
}
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path_filter = Some(path.into());
self
}
pub fn with_author(mut self, author: impl Into<String>) -> Self {
self.author_filter = Some(author.into());
self
}
pub fn with_date_range(mut self, since: Option<u64>, until: Option<u64>) -> Self {
self.since = since;
self.until = until;
self
}
pub fn with_changes(mut self) -> Self {
self.show_changes = true;
self
}
pub fn with_format(mut self, format: LogFormat) -> Self {
self.format = format;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogFormat {
Oneline,
Short,
Medium,
Full,
Custom,
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub hash: [u8; 32],
pub short_hash: String,
pub message: String,
pub author: String,
pub timestamp: u64,
pub txg: u64,
pub files_changed: usize,
pub changes: Option<Vec<FileChange>>,
pub is_merge: bool,
pub branches: Vec<String>,
}
impl LogEntry {
pub fn from_commit(commit: &Commit, include_changes: bool) -> Self {
Self {
hash: commit.hash,
short_hash: commit.short_hash(),
message: commit.message.clone(),
author: commit.author.clone(),
timestamp: commit.timestamp,
txg: commit.txg,
files_changed: commit.changes.len(),
changes: if include_changes {
Some(commit.changes.clone())
} else {
None
},
is_merge: Self::detect_merge_commit(commit),
branches: Vec::new(),
}
}
fn detect_merge_commit(commit: &Commit) -> bool {
let msg_lower = commit.message.to_lowercase();
msg_lower.starts_with("merge ")
|| msg_lower.starts_with("merge:")
|| msg_lower.starts_with("merged ")
}
pub fn format_oneline(&self) -> String {
alloc::format!("{} {}", self.short_hash, self.first_line())
}
pub fn format_short(&self) -> String {
alloc::format!(
"commit {}\nAuthor: {}\n\n {}\n",
self.short_hash,
self.author,
self.first_line()
)
}
pub fn format_medium(&self) -> String {
alloc::format!(
"commit {}\nAuthor: {}\nDate: {}\n\n {}\n",
self.short_hash,
self.author,
format_timestamp(self.timestamp),
self.first_line()
)
}
pub fn format_full(&self) -> String {
let mut output = alloc::format!(
"commit {}\nAuthor: {}\nDate: {}\nTXG: {}\n\n {}\n",
hex_string(&self.hash),
self.author,
format_timestamp(self.timestamp),
self.txg,
self.message.replace('\n', "\n ")
);
if let Some(ref changes) = self.changes {
output.push_str("\n Files changed:\n");
for change in changes {
output.push_str(&alloc::format!(
" {} {}\n",
change.change_type.short_name(),
change.path
));
}
}
output
}
fn first_line(&self) -> &str {
self.message.lines().next().unwrap_or(&self.message)
}
pub fn format(&self, format: LogFormat) -> String {
match format {
LogFormat::Oneline => self.format_oneline(),
LogFormat::Short => self.format_short(),
LogFormat::Medium => self.format_medium(),
LogFormat::Full => self.format_full(),
LogFormat::Custom => self.format_medium(), }
}
}
fn hex_string(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for byte in bytes {
s.push_str(&alloc::format!("{:02x}", byte));
}
s
}
fn format_timestamp(ts: u64) -> String {
let days_since_epoch = ts / 86400;
let time_of_day = ts % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let year = 1970 + (days_since_epoch / 365);
let day_of_year = days_since_epoch % 365;
let month = day_of_year / 30 + 1;
let day = day_of_year % 30 + 1;
alloc::format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
year,
month.min(12),
day.min(31),
hours,
minutes,
seconds
)
}
pub struct LogIterator<'a> {
commits: Vec<&'a Commit>,
position: usize,
options: LogOptions,
skipped: usize,
returned: usize,
}
impl<'a> LogIterator<'a> {
pub fn new(commits: Vec<&'a Commit>, options: LogOptions) -> Self {
Self {
commits,
position: 0,
options,
skipped: 0,
returned: 0,
}
}
fn passes_filters(&self, commit: &Commit) -> bool {
if let Some(ref author) = self.options.author_filter {
if !commit.author.contains(author) {
return false;
}
}
if let Some(since) = self.options.since {
if commit.timestamp < since {
return false;
}
}
if let Some(until) = self.options.until {
if commit.timestamp > until {
return false;
}
}
if let Some(ref path) = self.options.path_filter {
let has_path = commit.changes.iter().any(|c| c.path.contains(path));
if !has_path {
return false;
}
}
true
}
}
impl<'a> Iterator for LogIterator<'a> {
type Item = LogEntry;
fn next(&mut self) -> Option<Self::Item> {
if let Some(limit) = self.options.limit {
if self.returned >= limit {
return None;
}
}
while self.position < self.commits.len() {
let commit = self.commits[self.position];
self.position += 1;
if !self.passes_filters(commit) {
continue;
}
if self.skipped < self.options.skip {
self.skipped += 1;
continue;
}
self.returned += 1;
return Some(LogEntry::from_commit(commit, self.options.show_changes));
}
None
}
}
pub struct LogViewer<'a> {
store: &'a CommitStore,
}
impl<'a> LogViewer<'a> {
pub fn new(store: &'a CommitStore) -> Self {
Self { store }
}
pub fn log(&self, branch: &str, options: LogOptions) -> LogIterator<'a> {
let commits = self.store.branch_commits(branch, None);
LogIterator::new(commits, options)
}
pub fn log_from(&self, hash: &[u8; 32], options: LogOptions) -> LogIterator<'a> {
let commits = self.store.ancestry(hash, None);
LogIterator::new(commits, options)
}
pub fn log_range(
&self,
start: &[u8; 32],
end: &[u8; 32],
options: LogOptions,
) -> LogIterator<'a> {
let commits = self.store.range(Some(start), end);
LogIterator::new(commits, options)
}
pub fn file_history(&self, branch: &str, path: &str) -> Vec<LogEntry> {
let options = LogOptions::default().with_path(path).with_changes();
self.log(branch, options).collect()
}
pub fn count(&self, branch: &str) -> usize {
self.store.branch_commits(branch, None).len()
}
pub fn stats(&self, branch: &str) -> BranchStats {
let commits = self.store.branch_commits(branch, None);
let mut total_additions = 0usize;
let mut total_deletions = 0usize;
let mut total_modifications = 0usize;
let mut authors = Vec::new();
for commit in &commits {
for change in &commit.changes {
match change.change_type {
ChangeType::Created => total_additions += 1,
ChangeType::Deleted => total_deletions += 1,
ChangeType::Modified => total_modifications += 1,
ChangeType::Renamed { .. } => {}
}
}
if !authors.contains(&commit.author) {
authors.push(commit.author.clone());
}
}
let first_commit = commits.last();
let last_commit = commits.first();
BranchStats {
commit_count: commits.len(),
total_additions,
total_deletions,
total_modifications,
author_count: authors.len(),
first_commit_date: first_commit.map(|c| c.timestamp),
last_commit_date: last_commit.map(|c| c.timestamp),
}
}
pub fn shortlog(&self, branch: &str) -> Vec<(String, usize)> {
let commits = self.store.branch_commits(branch, None);
let mut counts: alloc::collections::BTreeMap<String, usize> =
alloc::collections::BTreeMap::new();
for commit in commits {
*counts.entry(commit.author.clone()).or_insert(0) += 1;
}
let mut result: Vec<_> = counts.into_iter().collect();
result.sort_by(|a, b| b.1.cmp(&a.1)); result
}
}
#[derive(Debug, Clone)]
pub struct BranchStats {
pub commit_count: usize,
pub total_additions: usize,
pub total_deletions: usize,
pub total_modifications: usize,
pub author_count: usize,
pub first_commit_date: Option<u64>,
pub last_commit_date: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct CommitGraph {
pub nodes: Vec<GraphNode>,
}
#[derive(Debug, Clone)]
pub struct GraphNode {
pub hash: [u8; 32],
pub column: usize,
pub parents: Vec<GraphEdge>,
pub branches: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct GraphEdge {
pub parent_hash: [u8; 32],
pub parent_column: usize,
}
impl CommitGraph {
pub fn build(commits: &[&Commit]) -> Self {
let mut nodes = Vec::new();
for (i, commit) in commits.iter().enumerate() {
let parents = if let Some(parent_hash) = commit.parent {
let parent_col = commits
.iter()
.position(|c| c.hash == parent_hash)
.unwrap_or(0);
vec![GraphEdge {
parent_hash,
parent_column: 0, }]
} else {
vec![]
};
nodes.push(GraphNode {
hash: commit.hash,
column: 0, parents,
branches: vec![],
});
}
Self { nodes }
}
pub fn render_ascii(&self, commits: &[&Commit]) -> Vec<String> {
let mut lines = Vec::new();
for (i, node) in self.nodes.iter().enumerate() {
let commit = commits.get(i);
let prefix = if node.parents.is_empty() { "* " } else { "| " };
if let Some(c) = commit {
lines.push(alloc::format!(
"{} {} {}",
prefix,
c.short_hash(),
first_line(&c.message)
));
}
}
lines
}
}
fn first_line(s: &str) -> &str {
s.lines().next().unwrap_or(s)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::branch::commit::CommitBuilder;
fn create_test_store() -> CommitStore {
let mut store = CommitStore::new();
let c1 = CommitBuilder::new(100)
.message("Initial commit")
.author("alice@example.com")
.timestamp(1704067200)
.change(FileChange::created("/README.md".into(), [1; 4], 100))
.build();
let c2 = CommitBuilder::new(101)
.parent(c1.hash)
.message("Add feature")
.author("bob@example.com")
.timestamp(1704153600)
.change(FileChange::created("/feature.rs".into(), [2; 4], 200))
.build();
let c3 = CommitBuilder::new(102)
.parent(c2.hash)
.message("Fix bug")
.author("alice@example.com")
.timestamp(1704240000)
.change(FileChange::modified(
"/feature.rs".into(),
[2; 4],
[3; 4],
200,
210,
))
.build();
store.add_commit(c1);
store.add_commit(c2);
store.add_commit_to_branch(c3, "main");
store
}
#[test]
fn test_log_default() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
let entries: Vec<_> = viewer.log("main", LogOptions::default()).collect();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].message, "Fix bug");
assert_eq!(entries[2].message, "Initial commit");
}
#[test]
fn test_log_limit() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
let entries: Vec<_> = viewer.log("main", LogOptions::last(2)).collect();
assert_eq!(entries.len(), 2);
}
#[test]
fn test_log_skip() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
let entries: Vec<_> = viewer
.log("main", LogOptions::default().with_skip(1))
.collect();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].message, "Add feature");
}
#[test]
fn test_log_author_filter() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
let entries: Vec<_> = viewer
.log("main", LogOptions::default().with_author("alice"))
.collect();
assert_eq!(entries.len(), 2);
for entry in entries {
assert!(entry.author.contains("alice"));
}
}
#[test]
fn test_log_path_filter() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
let entries: Vec<_> = viewer
.log("main", LogOptions::default().with_path("feature"))
.collect();
assert_eq!(entries.len(), 2); }
#[test]
fn test_file_history() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
let history = viewer.file_history("main", "/feature.rs");
assert_eq!(history.len(), 2);
assert!(history[0].changes.is_some());
}
#[test]
fn test_stats() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
let stats = viewer.stats("main");
assert_eq!(stats.commit_count, 3);
assert_eq!(stats.total_additions, 2);
assert_eq!(stats.total_modifications, 1);
assert_eq!(stats.author_count, 2);
}
#[test]
fn test_shortlog() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
let shortlog = viewer.shortlog("main");
assert_eq!(shortlog.len(), 2);
let alice = shortlog.iter().find(|(a, _)| a.contains("alice"));
assert!(alice.is_some());
assert_eq!(alice.unwrap().1, 2);
}
#[test]
fn test_log_entry_format_oneline() {
let commit = CommitBuilder::new(100)
.message("Test commit\nWith multiple lines")
.author("test")
.timestamp(1704067200)
.build();
let entry = LogEntry::from_commit(&commit, false);
let oneline = entry.format_oneline();
assert!(oneline.contains(&entry.short_hash));
assert!(oneline.contains("Test commit"));
assert!(!oneline.contains("With multiple lines"));
}
#[test]
fn test_log_entry_format_full() {
let commit = CommitBuilder::new(100)
.message("Test commit")
.author("test@example.com")
.timestamp(1704067200)
.change(FileChange::created("/file.txt".into(), [1; 4], 100))
.build();
let entry = LogEntry::from_commit(&commit, true);
let full = entry.format_full();
assert!(full.contains("test@example.com"));
assert!(full.contains("TXG: 100"));
assert!(full.contains("/file.txt"));
}
#[test]
fn test_count() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
assert_eq!(viewer.count("main"), 3);
assert_eq!(viewer.count("nonexistent"), 0);
}
#[test]
fn test_graph_build() {
let store = create_test_store();
let commits = store.branch_commits("main", None);
let graph = CommitGraph::build(&commits);
assert_eq!(graph.nodes.len(), 3);
}
#[test]
fn test_format_timestamp() {
let ts = format_timestamp(1704067200);
assert!(ts.contains("2024")); }
#[test]
fn test_log_date_filter() {
let store = create_test_store();
let viewer = LogViewer::new(&store);
let entries: Vec<_> = viewer
.log(
"main",
LogOptions::default().with_date_range(Some(1704100000), None),
)
.collect();
assert_eq!(entries.len(), 2);
}
}