1use super::{
4 compare::{CompareResult, compare_analysis},
5 fixture::{Fixture, discover_fixtures},
6};
7use crate::{
8 api::{AnalysisContext, generate_analysis_with_map_reduce},
9 config::CommitConfig,
10 error::Result,
11 normalization::format_commit_message,
12 tokens::create_token_counter,
13 types::{CommitType, ConventionalAnalysis, ConventionalCommit},
14};
15
16#[derive(Debug)]
18pub struct RunResult {
19 pub name: String,
21 pub comparison: Option<CompareResult>,
23 pub analysis: crate::types::ConventionalAnalysis,
25 pub final_message: String,
27 pub error: Option<String>,
29}
30
31pub struct TestRunner {
33 pub fixtures_dir: std::path::PathBuf,
35 pub config: CommitConfig,
37 pub filter: Option<String>,
39}
40
41impl TestRunner {
42 pub fn new(fixtures_dir: impl Into<std::path::PathBuf>, config: CommitConfig) -> Self {
44 Self { fixtures_dir: fixtures_dir.into(), config, filter: None }
45 }
46
47 pub fn with_filter(mut self, filter: Option<String>) -> Self {
49 self.filter = filter;
50 self
51 }
52
53 pub async fn run_all(&self) -> Result<Vec<RunResult>> {
55 let fixture_names = discover_fixtures(&self.fixtures_dir)?;
56 let mut results = Vec::new();
57
58 for name in fixture_names {
59 if let Some(pattern) = &self.filter
61 && !name.contains(pattern)
62 {
63 continue;
64 }
65
66 let result = self.run_fixture(&name).await;
67 results.push(result);
68 }
69
70 Ok(results)
71 }
72
73 pub async fn run_fixture(&self, name: &str) -> RunResult {
75 match self.run_fixture_inner(name).await {
76 Ok(result) => result,
77 Err(e) => RunResult {
78 name: name.to_string(),
79 comparison: None,
80 analysis: ConventionalAnalysis {
81 commit_type: CommitType::new("chore").expect("valid type"),
82 scope: None,
83 summary: None,
84 details: vec![],
85 issue_refs: vec![],
86 },
87 final_message: String::new(),
88 error: Some(e.to_string()),
89 },
90 }
91 }
92
93 async fn run_fixture_inner(&self, name: &str) -> Result<RunResult> {
94 let fixture = Fixture::load(&self.fixtures_dir, name)?;
95 let token_counter = create_token_counter(&self.config);
96
97 let debug_dir = std::env::var("LLM_GIT_TEST_DEBUG_DIR").ok().map(|root| {
99 let dir = std::path::PathBuf::from(root).join(name);
100 let _ = std::fs::create_dir_all(&dir);
101 dir
102 });
103
104 let ctx = AnalysisContext {
106 user_context: fixture.input.context.user_context.as_deref(),
107 recent_commits: fixture.input.context.recent_commits.as_deref(),
108 common_scopes: fixture.input.context.common_scopes.as_deref(),
109 project_context: fixture.input.context.project_context.as_deref(),
110 debug_output: debug_dir.as_deref(),
111 debug_prefix: None,
112 };
113
114 let analysis = generate_analysis_with_map_reduce(
116 &fixture.input.stat,
117 &fixture.input.diff,
118 &self.config.analysis_model,
119 &fixture.input.scope_candidates,
120 &ctx,
121 &self.config,
122 &token_counter,
123 )
124 .await?;
125
126 let detail_points = analysis.body_texts();
129 let summary = match crate::api::summary_from_holistic_analysis(&analysis, &self.config) {
130 Ok(Some(summary)) => summary,
131 Ok(None) => crate::api::generate_summary_from_analysis(
132 &fixture.input.stat,
133 analysis.commit_type.as_str(),
134 analysis.scope.as_ref().map(|s| s.as_str()),
135 &detail_points,
136 fixture.input.context.user_context.as_deref(),
137 &self.config,
138 None,
139 None,
140 )
141 .await
142 .unwrap_or_else(|_| {
143 crate::api::fallback_summary(
144 &fixture.input.stat,
145 &detail_points,
146 analysis.commit_type.as_str(),
147 &self.config,
148 )
149 }),
150 Err(_) => crate::api::fallback_summary(
151 &fixture.input.stat,
152 &detail_points,
153 analysis.commit_type.as_str(),
154 &self.config,
155 ),
156 };
157
158 let final_commit = ConventionalCommit {
159 commit_type: analysis.commit_type.clone(),
160 scope: analysis.scope.clone(),
161 summary,
162 body: detail_points,
163 footers: vec![],
164 };
165 let final_message = format_commit_message(&final_commit);
166
167 let comparison = fixture
169 .golden
170 .as_ref()
171 .map(|g| compare_analysis(&g.analysis, &analysis));
172
173 Ok(RunResult { name: name.to_string(), comparison, analysis, final_message, error: None })
174 }
175
176 pub async fn update_all(&self) -> Result<Vec<String>> {
178 let fixture_names = discover_fixtures(&self.fixtures_dir)?;
179 let mut updated = Vec::new();
180
181 for name in fixture_names {
182 if let Some(pattern) = &self.filter
183 && !name.contains(pattern)
184 {
185 continue;
186 }
187
188 self.update_fixture(&name).await?;
189 updated.push(name);
190 }
191
192 Ok(updated)
193 }
194
195 pub async fn update_fixture(&self, name: &str) -> Result<()> {
197 let result = self.run_fixture(name).await;
198
199 if let Some(err) = result.error {
200 return Err(crate::error::CommitGenError::Other(format!(
201 "Failed to run fixture '{name}': {err}"
202 )));
203 }
204
205 let mut fixture = Fixture::load(&self.fixtures_dir, name)?;
206 fixture.update_golden(result.analysis, result.final_message);
207 fixture.save(&self.fixtures_dir)?;
208
209 Ok(())
210 }
211}
212
213#[derive(Debug, Default)]
215pub struct TestSummary {
216 pub total: usize,
217 pub passed: usize,
218 pub failed: usize,
219 pub no_golden: usize,
220 pub errors: usize,
221}
222
223impl TestSummary {
224 pub fn from_results(results: &[RunResult]) -> Self {
226 let mut summary = Self { total: results.len(), ..Default::default() };
227
228 for result in results {
229 if result.error.is_some() {
230 summary.errors += 1;
231 } else if let Some(cmp) = &result.comparison {
232 if cmp.passed {
233 summary.passed += 1;
234 } else {
235 summary.failed += 1;
236 }
237 } else {
238 summary.no_golden += 1;
239 }
240 }
241
242 summary
243 }
244
245 pub const fn all_passed(&self) -> bool {
247 self.failed == 0 && self.errors == 0
248 }
249}