garbage_code_hunter/pr_title_hunter/
mod.rs1pub mod github;
7pub mod report;
8pub mod rules;
9pub mod types;
10
11use anyhow::{Context, Result};
12use git2::Repository;
13use report::{format_json, format_terminal};
14use rules::check_prs;
15use std::path::Path;
16
17use types::{PrEntry, PrSource};
18
19pub use crate::common::OutputFormat;
20
21pub fn run(repo_path: &Path, limit: usize, format: &OutputFormat) -> Result<String> {
25 let prs = extract_prs_from_merges(repo_path, limit)?;
26
27 if prs.is_empty() {
28 return match format {
29 OutputFormat::Terminal => Ok(format!(
30 "\n{}\n\n No merge commits found in {}\n",
31 "\u{1f3af} PR Title Roast Report \u{1f3af}",
32 repo_path.display()
33 )),
34 OutputFormat::Json => {
35 Ok(r#"{"score":100,"total_prs":0,"issues":[],"stats":{"avg_title_length":0.0,"issues_found":0}}"#.to_string())
36 }
37 };
38 }
39
40 let issues = check_prs(&prs);
41
42 let output = match format {
43 OutputFormat::Terminal => format_terminal(&prs, &issues),
44 OutputFormat::Json => format_json(&prs, &issues),
45 };
46
47 Ok(output)
48}
49
50fn extract_prs_from_merges(repo_path: &Path, limit: usize) -> Result<Vec<PrEntry>> {
52 let repo = Repository::open(repo_path)
53 .with_context(|| format!("Failed to open repo at {}", repo_path.display()))?;
54
55 let mut revwalk = repo.revwalk()?;
56 revwalk.set_sorting(git2::Sort::TIME)?;
57
58 let mut prs = Vec::new();
59
60 for oid_result in revwalk {
61 let oid = oid_result?;
62 let commit = repo.find_commit(oid)?;
63
64 if commit.parent_count() < 2 {
66 continue;
67 }
68
69 let message = commit.message().unwrap_or("");
70 let title = message.lines().next().unwrap_or("").trim();
71
72 if title.is_empty() {
74 continue;
75 }
76
77 let short_hash = format!("{:.7}", commit.id());
78 let author = commit.author().name().map(|s| s.to_string());
79
80 prs.push(PrEntry {
81 id: short_hash,
82 title: title.to_string(),
83 author,
84 source: PrSource::Local,
85 });
86
87 if prs.len() >= limit {
88 break;
89 }
90 }
91
92 Ok(prs)
93}
94
95pub fn analyze(repo_path: &Path, limit: usize) -> Result<(Vec<PrEntry>, Vec<types::PrIssue>)> {
97 let prs = extract_prs_from_merges(repo_path, limit)?;
98 let issues = check_prs(&prs);
99 Ok((prs, issues))
100}
101
102pub fn run_remote(config: &github::GitHubConfig, format: &OutputFormat) -> Result<String> {
104 let prs = github::fetch_prs(config)?;
105
106 if prs.is_empty() {
107 return match format {
108 OutputFormat::Terminal => Ok(format!(
109 "\n{}\n\n No PRs found for {}\n",
110 "\u{1f3af} PR Title Roast Report \u{1f3af}",
111 config.repo
112 )),
113 OutputFormat::Json => {
114 Ok(r#"{"score":100,"total_prs":0,"issues":[],"stats":{"avg_title_length":0.0,"issues_found":0}}"#.to_string())
115 }
116 };
117 }
118
119 let issues = check_prs(&prs);
120
121 let output = match format {
122 OutputFormat::Terminal => format_terminal(&prs, &issues),
123 OutputFormat::Json => format_json(&prs, &issues),
124 };
125
126 Ok(output)
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn test_run_on_nonexistent_path() {
135 let path = Path::new("/nonexistent/repo");
136 let result = run(path, 10, &OutputFormat::Terminal);
137 assert!(result.is_err());
138 }
139
140 #[test]
141 fn test_run_on_real_repo() {
142 let path = Path::new(".");
144 let result = run(path, 5, &OutputFormat::Terminal);
145 assert!(result.is_ok());
147 }
148
149 #[test]
150 fn test_run_json_format() {
151 let path = Path::new(".");
152 let result = run(path, 5, &OutputFormat::Json);
153 assert!(result.is_ok());
154 let json_str = result.unwrap();
155 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
156 assert!(parsed["score"].as_f64().is_some());
157 }
158}