use std::collections::BTreeMap;
#[cfg(feature = "git")]
use std::path::Path;
#[cfg(feature = "git")]
use tokmd_path::normalize_rel_path as normalize_path;
#[cfg(feature = "git")]
use tokmd_types::{FileKind, FileRow};
pub struct GitScores {
pub hotspots: BTreeMap<String, usize>,
pub commit_counts: BTreeMap<String, usize>,
}
#[cfg(feature = "git")]
pub fn compute_git_scores(
root: &Path,
rows: &[FileRow],
max_commits: usize,
max_commit_files: usize,
) -> Option<GitScores> {
let repo_root = tokmd_git::repo_root(root)?;
let commits =
tokmd_git::collect_history(&repo_root, Some(max_commits), Some(max_commit_files)).ok()?;
let file_lines: BTreeMap<String, usize> = rows
.iter()
.filter(|r| r.kind == FileKind::Parent)
.map(|r| (normalize_path(&r.path), r.lines))
.collect();
let mut commit_counts: BTreeMap<String, usize> = BTreeMap::new();
for commit in &commits {
for file in &commit.files {
let key = normalize_path(file);
if file_lines.contains_key(&key) {
*commit_counts.entry(key).or_insert(0) += 1;
}
}
}
let hotspots: BTreeMap<String, usize> = commit_counts
.iter()
.filter_map(|(path, commits)| {
let lines = file_lines.get(path)?;
Some((path.clone(), lines * commits))
})
.collect();
Some(GitScores {
hotspots,
commit_counts,
})
}
#[cfg(not(feature = "git"))]
pub fn compute_git_scores(
_root: &std::path::Path,
_rows: &[tokmd_types::FileRow],
_max_commits: usize,
_max_commit_files: usize,
) -> Option<GitScores> {
None
}
#[cfg(test)]
mod tests_no_feature {
use super::*;
#[test]
fn git_scores_can_be_constructed() {
let scores = GitScores {
hotspots: BTreeMap::new(),
commit_counts: BTreeMap::new(),
};
assert!(scores.hotspots.is_empty());
assert!(scores.commit_counts.is_empty());
}
#[test]
fn git_scores_btreemap_is_sorted() {
let mut hotspots = BTreeMap::new();
hotspots.insert("z/file.rs".to_string(), 100);
hotspots.insert("a/file.rs".to_string(), 50);
let scores = GitScores {
hotspots,
commit_counts: BTreeMap::new(),
};
let keys: Vec<&String> = scores.hotspots.keys().collect();
assert_eq!(keys[0], "a/file.rs");
assert_eq!(keys[1], "z/file.rs");
}
}
#[cfg(all(test, feature = "git"))]
mod tests {
use super::*;
use std::process::Command;
use tokmd_types::{FileKind, FileRow};
fn make_row(path: &str, lines: usize) -> FileRow {
FileRow {
path: path.to_string(),
module: "(root)".to_string(),
lang: "Rust".to_string(),
kind: FileKind::Parent,
code: lines,
comments: 0,
blanks: 0,
lines,
bytes: lines * 10,
tokens: lines * 5,
}
}
fn create_test_repo() -> Option<tempfile::TempDir> {
let dir = tempfile::tempdir().ok()?;
let root = dir.path();
Command::new("git")
.args(["init"])
.current_dir(root)
.output()
.ok()?;
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(root)
.output()
.ok()?;
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(root)
.output()
.ok()?;
std::fs::write(root.join("main.rs"), "1\n2\n3").ok()?;
Command::new("git")
.args(["add", "."])
.current_dir(root)
.output()
.ok()?;
Command::new("git")
.args(["commit", "-m", "c1"])
.current_dir(root)
.output()
.ok()?;
std::fs::write(root.join("main.rs"), "1\n2\n3\n4").ok()?;
Command::new("git")
.args(["add", "."])
.current_dir(root)
.output()
.ok()?;
Command::new("git")
.args(["commit", "-m", "c2"])
.current_dir(root)
.output()
.ok()?;
std::fs::write(root.join("lib.rs"), "1\n2\n3\n4\n5").ok()?;
Command::new("git")
.args(["add", "."])
.current_dir(root)
.output()
.ok()?;
Command::new("git")
.args(["commit", "-m", "c3"])
.current_dir(root)
.output()
.ok()?;
Some(dir)
}
#[test]
fn test_compute_git_scores_commit_counts() {
let repo = match create_test_repo() {
Some(r) => r,
None => return, };
let rows = vec![make_row("main.rs", 4), make_row("lib.rs", 5)];
let Some(scores) = compute_git_scores(repo.path(), &rows, 100, 100) else {
return; };
assert_eq!(scores.commit_counts.get("main.rs"), Some(&2));
assert_eq!(scores.commit_counts.get("lib.rs"), Some(&1));
}
#[test]
fn test_compute_git_scores_hotspots() {
let repo = match create_test_repo() {
Some(r) => r,
None => return,
};
let rows = vec![make_row("main.rs", 4), make_row("lib.rs", 5)];
let Some(scores) = compute_git_scores(repo.path(), &rows, 100, 100) else {
return;
};
assert_eq!(scores.hotspots.get("main.rs"), Some(&8));
assert_eq!(scores.hotspots.get("lib.rs"), Some(&5));
}
#[test]
fn test_compute_git_scores_filters_children() {
let repo = match create_test_repo() {
Some(r) => r,
None => return,
};
let rows = vec![FileRow {
path: "main.rs".to_string(),
module: "(root)".to_string(),
lang: "Rust".to_string(),
kind: FileKind::Child, code: 4,
comments: 0,
blanks: 0,
lines: 4,
bytes: 40,
tokens: 20,
}];
let scores = compute_git_scores(repo.path(), &rows, 100, 100);
let Some(scores) = scores else { return };
assert!(scores.commit_counts.is_empty());
}
#[test]
fn test_compute_git_scores_non_repo_returns_none() {
let dir = tempfile::tempdir().unwrap();
let rows = vec![];
assert!(compute_git_scores(dir.path(), &rows, 100, 100).is_none());
}
#[test]
fn test_normalize_path_backslash() {
assert_eq!(normalize_path("foo\\bar\\baz.rs"), "foo/bar/baz.rs");
}
#[test]
fn test_normalize_path_dot_slash() {
assert_eq!(normalize_path("./src/main.rs"), "src/main.rs");
}
#[test]
fn test_compute_git_scores_returns_some() {
let repo = match create_test_repo() {
Some(r) => r,
None => return,
};
let rows = vec![make_row("main.rs", 4)];
let result = compute_git_scores(repo.path(), &rows, 100, 100);
assert!(
result.is_some(),
"compute_git_scores should return Some for valid git repo"
);
}
#[test]
fn test_compute_git_scores_not_default() {
let repo = match create_test_repo() {
Some(r) => r,
None => return,
};
let rows = vec![make_row("main.rs", 4), make_row("lib.rs", 5)];
let Some(scores) = compute_git_scores(repo.path(), &rows, 100, 100) else {
return;
};
assert!(
!scores.commit_counts.is_empty(),
"commit_counts should not be empty"
);
assert!(!scores.hotspots.is_empty(), "hotspots should not be empty");
}
#[test]
fn test_commit_count_increment() {
let repo = match create_test_repo() {
Some(r) => r,
None => return,
};
let rows = vec![make_row("main.rs", 4)];
let Some(scores) = compute_git_scores(repo.path(), &rows, 100, 100) else {
return;
};
let count = scores.commit_counts.get("main.rs").copied().unwrap_or(0);
assert!(count > 0, "commit count must be positive, got {count}");
assert_eq!(count, 2, "main.rs should have exactly 2 commits");
}
#[test]
fn test_hotspot_multiplication() {
let repo = match create_test_repo() {
Some(r) => r,
None => return,
};
let rows = vec![make_row("main.rs", 4)];
let Some(scores) = compute_git_scores(repo.path(), &rows, 100, 100) else {
return;
};
let hotspot = scores.hotspots.get("main.rs").copied().unwrap_or(0);
assert_eq!(
hotspot, 8,
"hotspot should be lines * commits = 4 * 2 = 8, got {hotspot}"
);
}
#[test]
fn test_normalize_path_not_empty() {
assert!(!normalize_path("foo/bar").is_empty());
assert!(!normalize_path("test.rs").is_empty());
assert!(!normalize_path("./src/lib.rs").is_empty());
}
#[test]
fn test_normalize_path_not_xyzzy() {
assert_ne!(normalize_path("foo/bar"), "xyzzy");
assert_ne!(normalize_path("test.rs"), "xyzzy");
assert_ne!(normalize_path("./src/lib.rs"), "xyzzy");
}
#[test]
fn test_filter_only_parent_files() {
let repo = match create_test_repo() {
Some(r) => r,
None => return,
};
let rows = vec![
make_row("main.rs", 4), FileRow {
path: "lib.rs".to_string(),
module: "(root)".to_string(),
lang: "Rust".to_string(),
kind: FileKind::Child, code: 5,
comments: 0,
blanks: 0,
lines: 5,
bytes: 50,
tokens: 25,
},
];
let scores = compute_git_scores(repo.path(), &rows, 100, 100);
let Some(scores) = scores else { return };
assert!(scores.commit_counts.contains_key("main.rs"));
assert!(
!scores.commit_counts.contains_key("lib.rs"),
"Child file lib.rs should be filtered out"
);
}
#[test]
fn test_path_matching_with_normalization() {
let repo = match create_test_repo() {
Some(r) => r,
None => return,
};
let rows = vec![FileRow {
path: "main.rs".to_string(), module: "(root)".to_string(),
lang: "Rust".to_string(),
kind: FileKind::Parent,
code: 4,
comments: 0,
blanks: 0,
lines: 4,
bytes: 40,
tokens: 20,
}];
let Some(scores) = compute_git_scores(repo.path(), &rows, 100, 100) else {
return;
};
assert!(
scores.commit_counts.contains_key("main.rs"),
"Should match file after normalization"
);
}
}
#[cfg(test)]
mod tests_no_git {
use super::*;
#[test]
fn test_git_scores_struct_default() {
let scores = GitScores {
hotspots: BTreeMap::new(),
commit_counts: BTreeMap::new(),
};
assert!(scores.hotspots.is_empty());
assert!(scores.commit_counts.is_empty());
}
#[test]
fn test_git_scores_struct_with_data() {
let mut hotspots = BTreeMap::new();
hotspots.insert("src/main.rs".to_string(), 100);
hotspots.insert("src/lib.rs".to_string(), 50);
let mut commit_counts = BTreeMap::new();
commit_counts.insert("src/main.rs".to_string(), 10);
commit_counts.insert("src/lib.rs".to_string(), 5);
let scores = GitScores {
hotspots,
commit_counts,
};
assert_eq!(scores.hotspots.len(), 2);
assert_eq!(scores.commit_counts.get("src/main.rs"), Some(&10));
assert_eq!(scores.hotspots.get("src/lib.rs"), Some(&50));
}
#[test]
fn test_git_scores_btreemap_ordering() {
let mut hotspots = BTreeMap::new();
hotspots.insert("z.rs".to_string(), 1);
hotspots.insert("a.rs".to_string(), 2);
hotspots.insert("m.rs".to_string(), 3);
let scores = GitScores {
hotspots,
commit_counts: BTreeMap::new(),
};
let keys: Vec<&String> = scores.hotspots.keys().collect();
assert_eq!(keys, vec!["a.rs", "m.rs", "z.rs"]);
}
}