use crate::utils::project_paths::ProjectPaths;
use anyhow::{Result, anyhow};
use chrono::{DateTime, TimeZone, Utc};
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct CommitEntry {
pub hash: String,
pub done_items: Vec<String>,
pub date: DateTime<Utc>,
}
pub fn commit_tally_files(message: &str) -> Result<()> {
let paths = ProjectPaths::get_paths()?;
let mut files = vec!["TODO.md"];
if paths.changelog_file.exists() {
files.push("CHANGELOG.md");
}
let mut args = vec!["commit", "-m", message, "--"];
args.extend(files.iter().copied());
let output = Command::new("git")
.args(args)
.current_dir(&paths.root)
.output()?;
if !output.status.success() {
return Err(anyhow!(
"Failed to commit: {}",
String::from_utf8_lossy(&output.stderr)
));
}
println!("Committed {}", files.join(" and "));
Ok(())
}
pub fn scan_recent_commits(root: &Path, done_marker: &str) -> Result<Vec<CommitEntry>> {
let output = Command::new("git")
.args(["log", "--pretty=format:%h%x1f%ct%x1f%B%x1e", "-n", "50"])
.current_dir(root)
.output()?;
if !output.status.success() {
anyhow::bail!("failed to read git log");
}
let raw = String::from_utf8(output.stdout)?;
Ok(parse_commits(&raw, done_marker))
}
fn parse_commits(input: &str, done_marker: &str) -> Vec<CommitEntry> {
let mut commits = Vec::new();
for record in input.split('\x1e') {
let record = record.trim();
if record.is_empty() {
continue;
}
let mut parts = record.splitn(3, '\x1f');
let hash = parts.next().unwrap().to_string();
let timestamp_str = parts.next().unwrap_or("0");
let body = parts.next().unwrap_or("").trim();
let ts: i64 = timestamp_str.parse().unwrap_or(0);
let date: DateTime<Utc> = Utc
.timestamp_opt(ts, 0)
.single()
.unwrap_or_else(|| Utc.timestamp_opt(0, 0).unwrap());
let done_items = extract_done_items(body, done_marker);
if !done_items.is_empty() {
commits.push(CommitEntry {
hash,
done_items,
date,
});
}
}
commits
}
fn extract_done_items(message: &str, done_marker: &str) -> Vec<String> {
let mut items = Vec::new();
let mut in_done = false;
for line in message.lines() {
let trimmed = line.trim();
if trimmed.eq_ignore_ascii_case(done_marker) {
in_done = true;
continue;
}
if in_done {
if trimmed.is_empty() {
break;
}
if trimmed.ends_with(':') {
break;
}
let cleaned = trimmed.trim_start_matches(['-', '*']).trim().to_string();
if !cleaned.is_empty() {
items.push(cleaned);
}
}
}
items
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_done_items_parses_list_and_stops_on_blank_line() {
let message =
"feat: improve parser\n\nDone:\n- first item\n* second item\n\nNotes:\n- not included";
let items = extract_done_items(message, "done:");
assert_eq!(items, vec!["first item", "second item"]);
}
#[test]
fn extract_done_items_stops_on_next_header() {
let message = "done:\n- first\nNext Section:\n- should not be included";
let items = extract_done_items(message, "done:");
assert_eq!(items, vec!["first"]);
}
#[test]
fn parse_commits_includes_only_records_with_done_items() {
let input = concat!(
"abc123\x1f1700000000\x1fsubject\n\ndone:\n- ship feature\n\x1e",
"def456\x1f1700000050\x1fsubject without done marker\x1e"
);
let commits = parse_commits(input, "done:");
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].hash, "abc123");
assert_eq!(commits[0].done_items, vec!["ship feature"]);
assert_eq!(commits[0].date.timestamp(), 1700000000);
}
#[test]
fn parse_commits_uses_epoch_for_invalid_timestamp() {
let input = "abc123\x1fnot-a-number\x1fsubject\n\ndone:\n- keep\n\x1e";
let commits = parse_commits(input, "done:");
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].date.timestamp(), 0);
}
}