Skip to main content

llm_git/testing/
runner.rs

1//! Test runner for fixture-based testing
2
3use 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/// Result of running a single fixture
17#[derive(Debug)]
18pub struct RunResult {
19   /// Fixture name
20   pub name:          String,
21   /// Comparison result (None if no golden exists)
22   pub comparison:    Option<CompareResult>,
23   /// The actual analysis produced
24   pub analysis:      crate::types::ConventionalAnalysis,
25   /// The actual commit message produced
26   pub final_message: String,
27   /// Error if any
28   pub error:         Option<String>,
29}
30
31/// Test runner configuration
32pub struct TestRunner {
33   /// Fixtures directory
34   pub fixtures_dir: std::path::PathBuf,
35   /// Config to use for analysis
36   pub config:       CommitConfig,
37   /// Filter pattern for fixture names
38   pub filter:       Option<String>,
39}
40
41impl TestRunner {
42   /// Create a new test runner
43   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   /// Set filter pattern
48   pub fn with_filter(mut self, filter: Option<String>) -> Self {
49      self.filter = filter;
50      self
51   }
52
53   /// Run all fixtures and return results
54   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         // Apply filter if set
60         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   /// Run a single fixture
74   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      // Build analysis context from fixture
98      let ctx = AnalysisContext {
99         user_context:    fixture.input.context.user_context.as_deref(),
100         recent_commits:  fixture.input.context.recent_commits.as_deref(),
101         common_scopes:   fixture.input.context.common_scopes.as_deref(),
102         project_context: fixture.input.context.project_context.as_deref(),
103         debug_output:    None,
104         debug_prefix:    None,
105      };
106
107      // Run analysis
108      let analysis = generate_analysis_with_map_reduce(
109         &fixture.input.stat,
110         &fixture.input.diff,
111         &self.config.analysis_model,
112         &fixture.input.scope_candidates,
113         &ctx,
114         &self.config,
115         &token_counter,
116      )
117      .await?;
118
119      // Use the holistic title when analysis provided one; fixture runs for
120      // map-reduce or legacy output retain the separate summary path.
121      let detail_points = analysis.body_texts();
122      let summary = match crate::api::summary_from_holistic_analysis(&analysis, &self.config) {
123         Ok(Some(summary)) => summary,
124         Ok(None) => crate::api::generate_summary_from_analysis(
125            &fixture.input.stat,
126            analysis.commit_type.as_str(),
127            analysis.scope.as_ref().map(|s| s.as_str()),
128            &detail_points,
129            fixture.input.context.user_context.as_deref(),
130            &self.config,
131            None,
132            None,
133         )
134         .await
135         .unwrap_or_else(|_| {
136            crate::api::fallback_summary(
137               &fixture.input.stat,
138               &detail_points,
139               analysis.commit_type.as_str(),
140               &self.config,
141            )
142         }),
143         Err(_) => crate::api::fallback_summary(
144            &fixture.input.stat,
145            &detail_points,
146            analysis.commit_type.as_str(),
147            &self.config,
148         ),
149      };
150
151      let final_commit = ConventionalCommit {
152         commit_type: analysis.commit_type.clone(),
153         scope: analysis.scope.clone(),
154         summary,
155         body: detail_points,
156         footers: vec![],
157      };
158      let final_message = format_commit_message(&final_commit);
159
160      // Compare to golden if exists
161      let comparison = fixture
162         .golden
163         .as_ref()
164         .map(|g| compare_analysis(&g.analysis, &analysis));
165
166      Ok(RunResult { name: name.to_string(), comparison, analysis, final_message, error: None })
167   }
168
169   /// Update golden files for all fixtures
170   pub async fn update_all(&self) -> Result<Vec<String>> {
171      let fixture_names = discover_fixtures(&self.fixtures_dir)?;
172      let mut updated = Vec::new();
173
174      for name in fixture_names {
175         if let Some(pattern) = &self.filter
176            && !name.contains(pattern)
177         {
178            continue;
179         }
180
181         self.update_fixture(&name).await?;
182         updated.push(name);
183      }
184
185      Ok(updated)
186   }
187
188   /// Update golden file for a single fixture
189   pub async fn update_fixture(&self, name: &str) -> Result<()> {
190      let result = self.run_fixture(name).await;
191
192      if let Some(err) = result.error {
193         return Err(crate::error::CommitGenError::Other(format!(
194            "Failed to run fixture '{name}': {err}"
195         )));
196      }
197
198      let mut fixture = Fixture::load(&self.fixtures_dir, name)?;
199      fixture.update_golden(result.analysis, result.final_message);
200      fixture.save(&self.fixtures_dir)?;
201
202      Ok(())
203   }
204}
205
206/// Summary of test run
207#[derive(Debug, Default)]
208pub struct TestSummary {
209   pub total:     usize,
210   pub passed:    usize,
211   pub failed:    usize,
212   pub no_golden: usize,
213   pub errors:    usize,
214}
215
216impl TestSummary {
217   /// Create summary from results
218   pub fn from_results(results: &[RunResult]) -> Self {
219      let mut summary = Self { total: results.len(), ..Default::default() };
220
221      for result in results {
222         if result.error.is_some() {
223            summary.errors += 1;
224         } else if let Some(cmp) = &result.comparison {
225            if cmp.passed {
226               summary.passed += 1;
227            } else {
228               summary.failed += 1;
229            }
230         } else {
231            summary.no_golden += 1;
232         }
233      }
234
235      summary
236   }
237
238   /// Check if all tests passed
239   pub const fn all_passed(&self) -> bool {
240      self.failed == 0 && self.errors == 0
241   }
242}