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      // Optional per-fixture debug capture (raw LLM I/O) via env var.
98      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      // Build analysis context from fixture
105      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      // Run analysis
115      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      // Use the holistic title when analysis provided one; fixture runs for
127      // map-reduce or legacy output retain the separate summary path.
128      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      // Compare to golden if exists
168      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   /// Update golden files for all fixtures
177   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   /// Update golden file for a single fixture
196   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/// Summary of test run
214#[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   /// Create summary from results
225   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   /// Check if all tests passed
246   pub const fn all_passed(&self) -> bool {
247      self.failed == 0 && self.errors == 0
248   }
249}