diff --git a/Cargo.toml b/Cargo.toml index b685dd3..005b340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,4 +15,6 @@ tempfile = "3.3" open = "3.0" colored = "2.0" futures = "0.3" -dialoguer = "0.10.4" \ No newline at end of file +dialoguer = "0.10.4" +chrono = "0.4" +dirs = "5.0" \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index b3ae97f..ef35907 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,12 +9,13 @@ use colored::*; use futures::future::join_all; use dialoguer::Input; use serde::{Serialize, Deserialize}; +use chrono::{Local, Duration}; fn run_git_command(repo_path: &str, git_command: &str, index: Option) -> Result { let mut command = Command::new("sh"); command.arg("-c") .arg(format!("cd {} && {}", repo_path, git_command)); - + let index_str = index.map_or("".to_string(), |i| format!("[{}] ", i + 1)); println!("{}", format!("{}Running git command: {}", index_str, git_command).cyan()); @@ -100,22 +101,175 @@ fn save_commit_state(data_dir: &Path, state: &CommitState) { file.write_all(json.as_bytes()).expect("failed to write commit state file"); } -#[tokio::main] -async fn main() { - let args: Vec = env::args().collect(); - let repo_path = if args.len() > 1 { - args[1].clone() +/// Parse a time string in HH:MM format, returning (hour, minute). +fn parse_time(s: &str) -> Result<(u32, u32), String> { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 2 { + return Err(format!("Invalid time format '{}': expected HH:MM", s)); + } + let hour: u32 = parts[0].parse().map_err(|_| format!("Invalid hour in '{}'", s))?; + let minute: u32 = parts[1].parse().map_err(|_| format!("Invalid minute in '{}'", s))?; + if hour > 23 || minute > 59 { + return Err(format!("Time out of range: '{}'", s)); + } + Ok((hour, minute)) +} + +/// Generate a macOS LaunchAgent plist for scheduled execution. +fn generate_launchagent(hour: u32, minute: u32, repo_path: &str, git_command: &str) { + let binary = env::current_exe().unwrap_or_else(|_| "summarize_recent_commit".into()); + let binary_str = binary.display(); + + let plist = format!( + r#" + + + + Label + com.summarize-recent-commit.daily + + ProgramArguments + + {binary} + {repo} + {cmd} + + + StartCalendarInterval + + Hour + {hour} + Minute + {minute} + + + StandardOutPath + /tmp/summarize-recent-commit.log + StandardErrorPath + /tmp/summarize-recent-commit.err + + RunAtLoad + + +"#, + binary = binary_str, + repo = repo_path, + cmd = git_command, + hour = hour, + minute = minute, + ); + + let plist_path = dirs::home_dir() + .map(|h| h.join("Library/LaunchAgents/com.summarize-recent-commit.daily.plist")) + .unwrap_or_else(|| "com.summarize-recent-commit.daily.plist".into()); + + println!("{}", "--- macOS LaunchAgent ---".cyan().bold()); + println!("{}", format!("Plist path: {}", plist_path.display()).cyan()); + println!(); + println!("{}", plist); + println!(); + println!("{}", "To install:".green().bold()); + println!(" 1. Save the above to: {}", plist_path.display()); + println!(" 2. launchctl load {}", plist_path.display()); + println!(); + println!("{}", "To uninstall:".yellow()); + println!(" launchctl unload {}", plist_path.display()); +} + +/// Generate a crontab entry for scheduled execution. +fn generate_crontab(hour: u32, minute: u32, repo_path: &str, git_command: &str) { + let binary = env::current_exe().unwrap_or_else(|_| "summarize_recent_commit".into()); + let binary_str = binary.display(); + + let cron_line = format!( + "{minute} {hour} * * * {binary} '{repo}' '{cmd}' >> /tmp/summarize-recent-commit.log 2>&1", + minute = minute, + hour = hour, + binary = binary_str, + repo = repo_path, + cmd = git_command, + ); + + println!("{}", "--- Crontab Entry ---".cyan().bold()); + println!(); + println!("{}", cron_line); + println!(); + println!("{}", "To install:".green().bold()); + println!(" crontab -e # then paste the line above"); + println!(); + println!("{}", "Or append directly:".green()); + println!(" (crontab -l 2>/dev/null; echo '{}') | crontab -", cron_line); +} + +/// Print schedule configuration for the current platform. +fn handle_schedule(hour: u32, minute: u32, repo_path: &str, git_command: &str) { + println!("{}", format!("Generating schedule config: daily at {:02}:{:02}", hour, minute).green().bold()); + println!(); + + if cfg!(target_os = "macos") { + generate_launchagent(hour, minute, repo_path, git_command); + println!("{}", "--- Alternative: Crontab ---".cyan()); + println!(); + generate_crontab(hour, minute, repo_path, git_command); } else { - Input::new().with_prompt("Enter the repository absolute path").interact_text().unwrap() - }; + generate_crontab(hour, minute, repo_path, git_command); + } +} - let git_command = if args.len() > 2 { - args[2].clone() +/// Compute seconds until the next occurrence of HH:MM local time. +fn seconds_until(hour: u32, minute: u32) -> u64 { + let now = Local::now(); + let today = now.date_naive() + .and_hms_opt(hour, minute, 0) + .expect("valid time"); + let target = if now.naive_local() < today { + today } else { - Input::new().with_prompt("Enter the full git command").interact_text().unwrap() + today + Duration::days(1) }; + let diff = target - now.naive_local(); + diff.num_seconds().max(0) as u64 +} + +/// Run in foreground, waking at the scheduled time each day to execute the summarization. +async fn handle_watch(hour: u32, minute: u32, repo_path: &str, git_command: &str) { + println!("{}", format!( + "Watch mode: will run daily at {:02}:{:02}. Press Ctrl+C to stop.", + hour, minute + ).green().bold()); + println!("{}", format!(" repo: {}", repo_path).cyan()); + println!("{}", format!(" command: {}", git_command).cyan()); + println!(); + + loop { + let wait_secs = seconds_until(hour, minute); + let next_run = Local::now() + Duration::seconds(wait_secs as i64); + println!("{}", format!( + "Next run in {} hours {} minutes (at {})", + wait_secs / 3600, + (wait_secs % 3600) / 60, + next_run.format("%Y-%m-%d %H:%M") + ).cyan()); + + tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await; + + println!("{}", format!( + "\n=== Scheduled run at {} ===", + Local::now().format("%Y-%m-%d %H:%M:%S") + ).green().bold()); + + // Run the same summarization logic as the normal path + run_summarize(repo_path, git_command).await; + + // Sleep a brief moment to avoid re-triggering within the same minute + tokio::time::sleep(std::time::Duration::from_secs(61)).await; + } +} - match run_git_command(&repo_path, &git_command, None) { +/// Core summarization logic, extracted so both normal and watch modes can call it. +async fn run_summarize(repo_path: &str, git_command: &str) { + match run_git_command(repo_path, git_command, None) { Ok(changes) => { if changes.trim().is_empty() { eprintln!("{}", "No changes found in the specified range.".yellow()); @@ -139,20 +293,36 @@ async fn main() { let mut commit_state = load_commit_state(&data_dir); + // Save summaries in date-organized folders: data/summaries/YYYY-MM-DD/ + let now = Local::now(); + let date_str = now.format("%Y-%m-%d").to_string(); + let time_str = now.format("%H%M%S").to_string(); + let summaries_dir = current_dir.join("data").join("summaries").join(&date_str); + create_dir_all(&summaries_dir).expect("failed to create summaries directory"); + + let dated_filename = format!("summary-{}.md", time_str); + let dated_file_path = summaries_dir.join(&dated_filename); + + // Also write to the root file for backward compatibility let file_path = current_dir.join("git_commit_summaries.md"); let file_path_str = file_path.to_str().expect("Failed to convert file path to string"); let mut file = File::create(&file_path).expect("Failed to create file"); + let mut dated_file = File::create(&dated_file_path).expect("Failed to create dated summary file"); - writeln!(file, "# Git Commit Summaries\n").expect("Failed to write to file"); + println!("{}", format!("Summary will be saved to: {}", dated_file_path.display()).cyan()); - writeln!(file, "-----------------------------------------------------------------------").expect("Failed to write to file"); - writeln!(file, "-----------------------------------------------------------------------").expect("Failed to write to file"); - writeln!(file, " ").expect("Failed to write to file"); - writeln!(file, "PRESS CMD+SHIFT+V TO VIEW IN MARKDOWN").expect("Failed to write to file"); - writeln!(file, " ").expect("Failed to write to file"); - writeln!(file, "_______________________________________________________________________").expect("Failed to write to file"); - writeln!(file, "-----------------------------------------------------------------------").expect("Failed to write to file"); - writeln!(file, "Total number of commits: {}\n", commit_hashes.len()).expect("Failed to write to file"); + // Write header to both files + for f in [&mut file, &mut dated_file] { + writeln!(f, "# Git Commit Summaries — {}\n", date_str).expect("Failed to write to file"); + writeln!(f, "-----------------------------------------------------------------------").expect("Failed to write to file"); + writeln!(f, "-----------------------------------------------------------------------").expect("Failed to write to file"); + writeln!(f, " ").expect("Failed to write to file"); + writeln!(f, "PRESS CMD+SHIFT+V TO VIEW IN MARKDOWN").expect("Failed to write to file"); + writeln!(f, " ").expect("Failed to write to file"); + writeln!(f, "_______________________________________________________________________").expect("Failed to write to file"); + writeln!(f, "-----------------------------------------------------------------------").expect("Failed to write to file"); + writeln!(f, "Total number of commits: {}\n", commit_hashes.len()).expect("Failed to write to file"); + } let mut tasks = Vec::new(); @@ -195,10 +365,12 @@ async fn main() { for result in results { if let Ok(Some((index, commit_hash, summary))) = result { println!("{}", format!("processing commit {} of {}: {}", index + 1, commit_hashes.len(), commit_hash).cyan()); - writeln!(file, "
\nsummary for commit {} ({})\n\n{}\n
\n", index + 1, commit_hash, summary) - .expect("Failed to write to file"); - writeln!(file, "------------------------------------------------------------------------\n") - .expect("Failed to write to file"); + for f in [&mut file, &mut dated_file] { + writeln!(f, "
\nsummary for commit {} ({})\n\n{}\n
\n", index + 1, commit_hash, summary) + .expect("Failed to write to file"); + writeln!(f, "------------------------------------------------------------------------\n") + .expect("Failed to write to file"); + } combined_changes.push(summary); commit_state.processed_commits.push(commit_hash); } @@ -209,16 +381,104 @@ async fn main() { let overall_summary = summarize_changes(&combined_changes.join("\n")).await .unwrap_or_else(|e| format!("Error generating overall summary: {}", e)); - writeln!(file, "# Overall Summary of Changes\n\n{}", overall_summary) - .expect("Failed to write overall summary to file"); + for f in [&mut file, &mut dated_file] { + writeln!(f, "# Overall Summary of Changes\n\n{}", overall_summary) + .expect("Failed to write overall summary to file"); + } open_md_in_preview(file_path_str); println!("{}", "Job finished successfully!".green()); println!("{}", format!("Summary file created at: {}", file_path_str).green()); + println!("{}", format!("Dated copy saved to: {}", dated_file_path.display()).green()); save_commit_state(&data_dir, &commit_state); } Err(e) => eprintln!("{}", format!("Error running git command: {}", e).red()), } } + +fn print_usage() { + let binary = env::args().next().unwrap_or_else(|| "summarize_recent_commit".to_string()); + println!("{}", "Usage:".green().bold()); + println!(" {} ", binary); + println!(" {} --schedule [--time HH:MM] ", binary); + println!(" {} --watch [--time HH:MM] ", binary); + println!(); + println!("{}", "Options:".green().bold()); + println!(" --schedule Generate platform scheduling config (LaunchAgent/crontab)"); + println!(" --watch Run in foreground, executing daily at the scheduled time"); + println!(" --time Set schedule time (default: 09:00)"); + println!(" --help Show this help message"); + println!(); + println!("{}", "Examples:".green().bold()); + println!(" {} /path/to/repo 'git log --since=\"24 hours ago\"'", binary); + println!(" {} --schedule /path/to/repo 'git log --since=\"24 hours ago\"'", binary); + println!(" {} --watch --time 08:30 /path/to/repo 'git log --since=\"24 hours ago\"'", binary); +} + +#[tokio::main] +async fn main() { + let args: Vec = env::args().skip(1).collect(); + + // Parse flags: --schedule, --watch, --time HH:MM, --help + let mut schedule = false; + let mut watch = false; + let mut time_str = "09:00".to_string(); + let mut positional: Vec = Vec::new(); + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--schedule" => schedule = true, + "--watch" => watch = true, + "--time" => { + i += 1; + if i >= args.len() { + eprintln!("{}", "Error: --time requires a value (e.g., --time 09:00)".red()); + std::process::exit(1); + } + time_str = args[i].clone(); + } + "--help" | "-h" => { + print_usage(); + return; + } + _ => positional.push(args[i].clone()), + } + i += 1; + } + + // Resolve repo_path and git_command from positional args or interactive prompts + let repo_path = if !positional.is_empty() { + positional[0].clone() + } else { + Input::new().with_prompt("Enter the repository absolute path").interact_text().unwrap() + }; + + let git_command = if positional.len() > 1 { + positional[1].clone() + } else { + Input::new().with_prompt("Enter the full git command").interact_text().unwrap() + }; + + if schedule || watch { + let (hour, minute) = match parse_time(&time_str) { + Ok(t) => t, + Err(e) => { + eprintln!("{}", format!("Error: {}", e).red()); + std::process::exit(1); + } + }; + + if schedule { + handle_schedule(hour, minute, &repo_path, &git_command); + } + if watch { + handle_watch(hour, minute, &repo_path, &git_command).await; + } + } else { + // Normal one-shot mode (original behavior) + run_summarize(&repo_path, &git_command).await; + } +}