use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use proptest::prelude::*;
use tokmd_git::{GitCommit, GitRangeMode, classify_intent, git_available, repo_root};
use tokmd_types::CommitIntentKind;
fn arb_valid_timestamp() -> impl Strategy<Value = String> {
(946684800i64..1893456000i64).prop_map(|ts| ts.to_string())
}
fn arb_invalid_timestamp() -> impl Strategy<Value = String> {
prop_oneof![
Just("".to_string()),
Just("not_a_number".to_string()),
Just("-1".to_string()),
Just("abc123".to_string()),
Just("12.34".to_string()),
Just("9999999999999999999999".to_string()),
"[a-z]{1,10}".prop_map(|s| s),
]
}
fn arb_author_email() -> impl Strategy<Value = String> {
prop_oneof![
"[a-z]{1,10}@[a-z]{1,10}\\.[a-z]{2,4}".prop_map(|s| s),
"[a-z]{1,20}".prop_map(|s| s),
"[a-z.]{1,15}@[a-z]{1,10}\\.[a-z]{2,4}".prop_map(|s| s),
]
}
fn arb_git_log_line() -> impl Strategy<Value = String> {
(
arb_valid_timestamp(),
arb_author_email(),
"[a-zA-Z0-9 _-]{0,50}",
)
.prop_map(|(ts, author, subject)| format!("{}|{}|{}", ts, author, subject))
}
fn arb_malformed_git_log_line() -> impl Strategy<Value = String> {
prop_oneof![
arb_valid_timestamp(),
(
arb_valid_timestamp(),
arb_author_email(),
"[a-z]{1,10}",
"[a-z]{1,10}"
)
.prop_map(|(ts, author, subj, extra)| format!("{}|{}|{}|{}", ts, author, subj, extra)),
Just("".to_string()),
Just("|".to_string()),
arb_author_email().prop_map(|author| format!("|{}", author)),
arb_valid_timestamp().prop_map(|ts| format!("{}|", ts)),
(arb_invalid_timestamp(), arb_author_email())
.prop_map(|(ts, author)| format!("{}|{}", ts, author)),
]
}
fn arb_file_path() -> impl Strategy<Value = String> {
prop_oneof![
"[a-z]{1,10}\\.[a-z]{1,5}".prop_map(|s| s),
prop::collection::vec("[a-z]{1,10}", 1..=5).prop_map(|parts| {
let mut path = parts.join("/");
path.push_str(".rs");
path
}),
prop::collection::vec("[a-z0-9_-]{1,15}", 5..=10).prop_map(|parts| {
let mut path = parts.join("/");
path.push_str(".txt");
path
}),
]
}
fn arb_long_file_path() -> impl Strategy<Value = String> {
prop::collection::vec("[a-z]{10,20}", 10..=20).prop_map(|parts| {
let mut path = parts.join("/");
path.push_str(".rs");
path
})
}
fn parse_diff_output(stdout: &str) -> BTreeMap<PathBuf, BTreeSet<usize>> {
let mut result: BTreeMap<PathBuf, BTreeSet<usize>> = BTreeMap::new();
let mut current_file: Option<PathBuf> = None;
for line in stdout.lines() {
if let Some(file_path) = line.strip_prefix("+++ b/") {
current_file = Some(PathBuf::from(file_path));
continue;
}
if line.starts_with("@@") {
let Some(file) = current_file.as_ref() else {
continue;
};
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
continue;
}
let new_range = parts[2];
let range_str = new_range.strip_prefix('+').unwrap_or(new_range);
let range_parts: Vec<&str> = range_str.split(',').collect();
let start: usize = range_parts[0].parse().unwrap_or(0);
let count: usize = if range_parts.len() > 1 {
range_parts[1].parse().unwrap_or(1)
} else {
1
};
if count > 0 && start > 0 {
let set = result.entry(file.clone()).or_default();
for i in 0..count {
set.insert(start + i);
}
}
}
}
result
}
fn parse_header_line(line: &str) -> (i64, String, String, String) {
let mut parts = line.splitn(4, '|');
let ts = parts.next().unwrap_or("0").parse::<i64>().unwrap_or(0);
let author = parts.next().unwrap_or("").to_string();
let hash = parts.next().unwrap_or("").to_string();
let subject = parts.next().unwrap_or("").to_string();
(ts, author, hash, subject)
}
fn apply_file_limit(files: Vec<String>, limit: Option<usize>) -> Vec<String> {
match limit {
Some(max) => files.into_iter().take(max).collect(),
None => files,
}
}
fn apply_commit_limit(commits: Vec<GitCommit>, limit: Option<usize>) -> Vec<GitCommit> {
match limit {
Some(max) => commits.into_iter().take(max).collect(),
None => commits,
}
}
proptest! {
#[test]
fn valid_timestamp_parses(
ts in 0i64..2000000000i64,
author in arb_author_email()
) {
let line = format!("{}|{}", ts, author);
let (parsed_ts, parsed_author, _, _) = parse_header_line(&line);
prop_assert_eq!(parsed_ts, ts, "Timestamp should parse correctly");
prop_assert_eq!(parsed_author, author, "Author should parse correctly");
}
#[test]
fn invalid_timestamp_defaults_to_zero(
invalid_ts in arb_invalid_timestamp(),
author in arb_author_email()
) {
let line = format!("{}|{}", invalid_ts, author);
let (parsed_ts, parsed_author, _, _) = parse_header_line(&line);
let _ = parsed_ts; prop_assert_eq!(parsed_author, author, "Author should still parse correctly");
}
#[test]
fn empty_author_is_empty_string(ts in arb_valid_timestamp()) {
let line = format!("{}|", ts);
let (_, parsed_author, _, _) = parse_header_line(&line);
prop_assert_eq!(parsed_author, "", "Empty author should be empty string");
}
#[test]
fn missing_pipe_produces_empty_author(ts in arb_valid_timestamp()) {
let line = ts.clone();
let (parsed_ts, parsed_author, _, _) = parse_header_line(&line);
let expected_ts = ts.parse::<i64>().unwrap_or(0);
prop_assert_eq!(parsed_ts, expected_ts, "Timestamp should parse");
prop_assert_eq!(parsed_author, "", "Author should be empty when no pipe");
}
#[test]
fn only_pipe_separator(dummy in 0u8..1) {
let _ = dummy;
let line = "|";
let (parsed_ts, parsed_author, _, _) = parse_header_line(line);
prop_assert_eq!(parsed_ts, 0, "Empty timestamp should be 0");
prop_assert_eq!(parsed_author, "", "Empty author should be empty string");
}
#[test]
fn empty_line_produces_defaults(dummy in 0u8..1) {
let _ = dummy;
let line = "";
let (parsed_ts, parsed_author, _, _) = parse_header_line(line);
prop_assert_eq!(parsed_ts, 0, "Empty line should produce timestamp 0");
prop_assert_eq!(parsed_author, "", "Empty line should produce empty author");
}
#[test]
fn git_commit_construction(
ts in 0i64..2000000000i64,
author in arb_author_email(),
files in prop::collection::vec(arb_file_path(), 0..20)
) {
let commit = GitCommit {
timestamp: ts,
author: author.clone(),
hash: None,
subject: String::new(),
files: files.clone(),
};
prop_assert_eq!(commit.timestamp, ts);
prop_assert_eq!(commit.author, author);
prop_assert_eq!(commit.files.len(), files.len());
}
#[test]
fn timestamp_is_valid_i64(line in arb_malformed_git_log_line()) {
let (parsed_ts, _, _, _) = parse_header_line(&line);
prop_assert!(
parsed_ts == 0 || parsed_ts == -1 || parsed_ts > 0,
"Malformed input should parse to 0, -1, or a valid positive timestamp"
);
}
#[test]
fn author_is_valid_utf8(line in arb_git_log_line()) {
let (_, parsed_author, _, _) = parse_header_line(&line);
prop_assert!(parsed_author.is_ascii() || !parsed_author.is_empty() || parsed_author.is_empty());
}
#[test]
fn file_limit_is_respected(
files in prop::collection::vec(arb_file_path(), 0..50),
limit in 0usize..20
) {
let limited = apply_file_limit(files.clone(), Some(limit));
prop_assert!(
limited.len() <= limit,
"File count {} should not exceed limit {}",
limited.len(),
limit
);
}
#[test]
fn no_limit_returns_all(files in prop::collection::vec(arb_file_path(), 0..50)) {
let limited = apply_file_limit(files.clone(), None);
prop_assert_eq!(limited.len(), files.len(), "All files should be returned");
}
#[test]
fn limit_zero_returns_empty(files in prop::collection::vec(arb_file_path(), 1..50)) {
let limited = apply_file_limit(files, Some(0));
prop_assert!(limited.is_empty(), "Limit 0 should return empty list");
}
#[test]
fn long_author_email_handled(
prefix in "[a-z]{50,100}",
domain in "[a-z]{20,50}"
) {
let long_email = format!("{}@{}.com", prefix, domain);
let line = format!("1234567890|{}", long_email);
let (parsed_ts, parsed_author, _, _) = parse_header_line(&line);
prop_assert_eq!(parsed_ts, 1234567890);
prop_assert_eq!(parsed_author, long_email);
}
#[test]
fn long_file_path_handled(path in arb_long_file_path()) {
let commit = GitCommit {
timestamp: 1234567890,
author: "test@example.com".to_string(),
hash: None,
subject: String::new(),
files: vec![path.clone()],
};
prop_assert_eq!(&commit.files[0], &path);
prop_assert!(commit.files[0].len() > 100, "Path should be long");
}
#[test]
fn four_field_parsing(
ts in arb_valid_timestamp(),
author in "[a-z]{1,10}",
hash in "[0-9a-f]{40}",
subject in "[a-z]{1,10}"
) {
let line = format!("{}|{}|{}|{}", ts, author, hash, subject);
let (_, parsed_author, parsed_hash, parsed_subject) = parse_header_line(&line);
prop_assert_eq!(parsed_author, author, "Author should be second field");
prop_assert_eq!(parsed_hash, hash, "Hash should be third field");
prop_assert_eq!(parsed_subject, subject, "Subject should be fourth field");
}
#[test]
fn subject_with_pipes(
ts in arb_valid_timestamp(),
author in "[a-z]{1,10}",
hash in "[0-9a-f]{40}",
part1 in "[a-z]{1,10}",
part2 in "[a-z]{1,10}"
) {
let subject = format!("{}|{}", part1, part2);
let line = format!("{}|{}|{}|{}", ts, author, hash, subject);
let (_, parsed_author, _parsed_hash, parsed_subject) = parse_header_line(&line);
prop_assert_eq!(parsed_author, author, "Author should be second field");
prop_assert_eq!(parsed_subject, subject, "Subject should contain pipe");
}
#[test]
fn whitespace_line_parses(spaces in "[ \t]{1,20}") {
let (parsed_ts, _, _, _) = parse_header_line(&spaces);
prop_assert_eq!(parsed_ts, 0, "Whitespace should not parse as valid timestamp");
}
#[test]
fn negative_timestamp_is_valid(
ts in -1000000000i64..0i64,
author in arb_author_email()
) {
let line = format!("{}|{}", ts, author);
let (parsed_ts, parsed_author, _, _) = parse_header_line(&line);
prop_assert_eq!(parsed_ts, ts, "Negative timestamp should parse correctly");
prop_assert_eq!(parsed_author, author);
}
}
#[test]
fn git_available_never_panics() {
let _ = git_available();
}
#[test]
fn repo_root_edge_cases_never_panic() {
let _ = repo_root(std::path::Path::new(""));
let _ = repo_root(std::path::Path::new("."));
let _ = repo_root(std::path::Path::new(".."));
let _ = repo_root(std::path::Path::new("/"));
#[cfg(windows)]
let _ = repo_root(std::path::Path::new(r"C:\"));
let _ = repo_root(std::path::Path::new(
"/nonexistent/deep/path/that/does/not/exist",
));
let _ = repo_root(std::path::Path::new("nonexistent/relative/path"));
}
#[test]
fn repo_root_finds_git_dir_in_ancestors() {
if !git_available() {
eprintln!("git not available; skipping repo_root correctness tests");
return;
}
let dir = tempfile::tempdir().unwrap();
let status = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir.path())
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.status()
.expect("failed to spawn git");
assert!(status.success(), "git init failed: {status}");
let result = repo_root(dir.path());
assert!(result.is_some(), "repo_root should find the git repo");
let expected = dir.path().canonicalize().unwrap();
let actual = result.unwrap().canonicalize().unwrap();
assert_eq!(actual, expected);
}
#[test]
fn repo_root_finds_git_dir_from_nested_path() {
if !git_available() {
eprintln!("git not available; skipping repo_root correctness tests");
return;
}
let dir = tempfile::tempdir().unwrap();
let status = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir.path())
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.status()
.expect("failed to spawn git");
assert!(status.success(), "git init failed: {status}");
let nested = dir.path().join("src").join("lib");
std::fs::create_dir_all(&nested).unwrap();
let result = repo_root(&nested);
assert!(
result.is_some(),
"repo_root should find the git repo from nested path"
);
let expected = dir.path().canonicalize().unwrap();
let actual = result.unwrap().canonicalize().unwrap();
assert_eq!(actual, expected);
}
#[test]
fn repo_root_returns_none_without_git_dir() {
if !git_available() {
eprintln!("git not available; skipping repo_root tests");
return;
}
let dir = tempfile::tempdir().unwrap();
if repo_root(dir.path()).is_some() {
eprintln!("tempdir appears to be inside an existing git repo; skipping negative case");
return;
}
assert_eq!(repo_root(dir.path()), None);
}
proptest! {
#[test]
fn parsing_is_deterministic(line in arb_git_log_line()) {
let (ts1, author1, _, _) = parse_header_line(&line);
let (ts2, author2, _, _) = parse_header_line(&line);
prop_assert_eq!(ts1, ts2, "Timestamp parsing should be deterministic");
prop_assert_eq!(author1, author2, "Author parsing should be deterministic");
}
}
proptest! {
#[test]
fn commit_limit_is_respected(
commit_count in 0usize..50,
limit in 1usize..20
) {
let commits: Vec<GitCommit> = (0..commit_count)
.map(|i| GitCommit {
timestamp: i as i64,
author: format!("author{}@example.com", i),
hash: None,
subject: String::new(),
files: vec![format!("file{}.rs", i)],
})
.collect();
let limited = apply_commit_limit(commits, Some(limit));
prop_assert!(
limited.len() <= limit,
"Commit count {} should not exceed limit {}",
limited.len(),
limit
);
}
#[test]
fn no_commit_limit_returns_all(commit_count in 0usize..50) {
let commits: Vec<GitCommit> = (0..commit_count)
.map(|i| GitCommit {
timestamp: i as i64,
author: format!("author{}@example.com", i),
hash: None,
subject: String::new(),
files: vec![format!("file{}.rs", i)],
})
.collect();
let limited = apply_commit_limit(commits.clone(), None);
prop_assert_eq!(limited.len(), commits.len(), "All commits should be returned");
}
}
proptest! {
#[test]
fn hunk_with_count_produces_consecutive_lines(
file in arb_file_path(),
start in 1usize..1000,
count in 1usize..100,
) {
let diff = format!(
"+++ b/{}\n@@ -1,1 +{},{} @@\n",
file, start, count
);
let result = parse_diff_output(&diff);
let expected: BTreeSet<usize> = (start..start + count).collect();
let file_path = PathBuf::from(&file);
prop_assert_eq!(
result.get(&file_path),
Some(&expected),
"Hunk +{},{} should produce lines {}..{}",
start, count, start, start + count
);
}
#[test]
fn hunk_with_zero_count_produces_no_lines(
file in arb_file_path(),
start in 1usize..1000,
) {
let diff = format!(
"+++ b/{}\n@@ -1,1 +{},0 @@\n",
file, start
);
let result = parse_diff_output(&diff);
prop_assert!(
result.is_empty(),
"Zero count should produce no lines, got: {:?}",
result
);
}
#[test]
fn no_hunk_headers_produces_empty_result(
text in "[a-zA-Z0-9 \n]{0,500}"
) {
let result = parse_diff_output(&text);
prop_assert!(
result.is_empty(),
"Text without hunk headers should produce empty result, got: {:?}",
result
);
}
#[test]
fn diff_parsing_is_deterministic(
file in arb_file_path(),
start in 1usize..1000,
count in 1usize..100,
) {
let diff = format!(
"+++ b/{}\n@@ -1,1 +{},{} @@\n",
file, start, count
);
let r1 = parse_diff_output(&diff);
let r2 = parse_diff_output(&diff);
prop_assert_eq!(r1, r2, "Parsing should be deterministic");
}
}
fn arb_conventional_subject() -> impl Strategy<Value = String> {
let types = prop_oneof![
Just("feat"),
Just("fix"),
Just("refactor"),
Just("docs"),
Just("test"),
Just("chore"),
Just("ci"),
Just("build"),
Just("perf"),
Just("style"),
Just("revert"),
Just("bugfix"),
Just("hotfix"),
Just("feature"),
Just("doc"),
Just("tests"),
];
let scope = prop_oneof![
Just("".to_string()),
"[a-z]{1,8}".prop_map(|s| format!("({})", s)),
];
let bang = prop_oneof![Just(""), Just("!")];
let desc = "[a-zA-Z0-9 ]{1,40}";
(types, scope, bang, desc).prop_map(|(t, s, b, d)| format!("{}{}{}: {}", t, s, b, d))
}
fn arb_freeform_subject() -> impl Strategy<Value = String> {
prop_oneof![
"[a-zA-Z ]{1,60}".prop_map(|s| s),
Just("Fix crash on startup".to_string()),
Just("Add user authentication".to_string()),
Just("Update readme".to_string()),
Just("WIP".to_string()),
Just("v1.0.0".to_string()),
Just("".to_string()),
Just(" ".to_string()),
]
}
proptest! {
#[test]
fn classify_intent_never_panics(subject in ".*") {
let _ = classify_intent(&subject);
}
#[test]
fn classify_intent_returns_valid_variant(subject in arb_freeform_subject()) {
let kind = classify_intent(&subject);
match kind {
CommitIntentKind::Feat
| CommitIntentKind::Fix
| CommitIntentKind::Refactor
| CommitIntentKind::Docs
| CommitIntentKind::Test
| CommitIntentKind::Chore
| CommitIntentKind::Ci
| CommitIntentKind::Build
| CommitIntentKind::Perf
| CommitIntentKind::Style
| CommitIntentKind::Revert
| CommitIntentKind::Other => {} }
}
#[test]
fn classify_intent_is_deterministic(subject in ".*") {
let a = classify_intent(&subject);
let b = classify_intent(&subject);
prop_assert_eq!(a, b, "classify_intent should be deterministic");
}
#[test]
fn conventional_commits_never_classify_as_other(subject in arb_conventional_subject()) {
let kind = classify_intent(&subject);
prop_assert_ne!(
kind,
CommitIntentKind::Other,
"Conventional commit '{}' should not classify as Other",
subject
);
}
#[test]
fn blank_subjects_are_other(spaces in "[ \t\n\r]{0,20}") {
let kind = classify_intent(&spaces);
prop_assert_eq!(
kind,
CommitIntentKind::Other,
"Blank input '{}' should be Other",
spaces
);
}
#[test]
fn range_format_contains_base_and_head(
base in "[a-zA-Z0-9/_.-]{1,30}",
head in "[a-zA-Z0-9/_.-]{1,30}",
mode in prop_oneof![Just(GitRangeMode::TwoDot), Just(GitRangeMode::ThreeDot)]
) {
let formatted = mode.format(&base, &head);
prop_assert!(
formatted.contains(&base),
"Formatted range '{}' should contain base '{}'",
formatted, base
);
prop_assert!(
formatted.contains(&head),
"Formatted range '{}' should contain head '{}'",
formatted, head
);
}
#[test]
fn two_dot_format_has_double_dot(
base in "[a-z]{1,10}",
head in "[a-z]{1,10}"
) {
let formatted = GitRangeMode::TwoDot.format(&base, &head);
prop_assert!(formatted.contains(".."), "Should contain ..");
let without_range = formatted.replacen("..", "", 1);
prop_assert!(
!without_range.contains(".."),
"Should not contain extra double-dots after removing the range separator"
);
}
#[test]
fn three_dot_format_has_triple_dot(
base in "[a-z]{1,10}",
head in "[a-z]{1,10}"
) {
let formatted = GitRangeMode::ThreeDot.format(&base, &head);
prop_assert!(formatted.contains("..."), "Should contain ...");
}
#[test]
fn range_format_is_deterministic(
base in "[a-z]{1,10}",
head in "[a-z]{1,10}",
mode in prop_oneof![Just(GitRangeMode::TwoDot), Just(GitRangeMode::ThreeDot)]
) {
let a = mode.format(&base, &head);
let b = mode.format(&base, &head);
prop_assert_eq!(a, b, "format should be deterministic");
}
#[test]
fn two_hunks_same_file_accumulate(
file in arb_file_path(),
start1 in 1usize..500,
count1 in 1usize..50,
gap in 50usize..200,
count2 in 1usize..50,
) {
let start2 = start1 + count1 + gap;
let diff = format!(
"+++ b/{f}\n@@ -1,1 +{s1},{c1} @@\n@@ -{s2},1 +{s2},{c2} @@\n",
f = file, s1 = start1, c1 = count1, s2 = start2, c2 = count2
);
let result = parse_diff_output(&diff);
let key = PathBuf::from(&file);
let lines = result.get(&key).expect("file should be present");
let expected_count = count1 + count2;
prop_assert_eq!(
lines.len(), expected_count,
"Two disjoint hunks should produce {} lines, got {}",
expected_count, lines.len()
);
for i in 0..count1 {
prop_assert!(lines.contains(&(start1 + i)));
}
for i in 0..count2 {
prop_assert!(lines.contains(&(start2 + i)));
}
}
#[test]
fn multiple_files_in_diff(
file1 in "[a-z]{1,8}\\.rs",
file2 in "[a-z]{1,8}\\.txt",
start1 in 1usize..100,
start2 in 1usize..100,
) {
prop_assume!(file1 != file2);
let diff = format!(
"+++ b/{f1}\n@@ -1,1 +{s1},1 @@\n+++ b/{f2}\n@@ -1,1 +{s2},1 @@\n",
f1 = file1, s1 = start1, f2 = file2, s2 = start2
);
let result = parse_diff_output(&diff);
prop_assert_eq!(result.len(), 2, "Should have 2 files");
prop_assert!(result.contains_key(&PathBuf::from(&file1)));
prop_assert!(result.contains_key(&PathBuf::from(&file2)));
}
#[test]
fn hunk_start_zero_produces_no_lines(
file in arb_file_path(),
count in 1usize..50,
) {
let diff = format!(
"+++ b/{}\n@@ -1,1 +0,{} @@\n",
file, count
);
let result = parse_diff_output(&diff);
prop_assert!(
result.is_empty(),
"start=0 should produce no lines, got: {:?}",
result
);
}
}