llm_git/testing/
fixture.rs

1//! Fixture types and I/O operations
2
3use std::{collections::HashMap, fs, path::Path};
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8   error::{CommitGenError, Result},
9   types::ConventionalAnalysis,
10};
11
12/// Manifest listing all fixtures
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Manifest {
15   /// Map of fixture name to metadata
16   #[serde(default)]
17   pub fixtures: HashMap<String, FixtureEntry>,
18}
19
20/// Entry in the manifest for a single fixture
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct FixtureEntry {
23   /// Brief description of what this fixture tests
24   pub description: String,
25   /// Tags for filtering (e.g., "large", "map-reduce", "edge-case")
26   #[serde(default)]
27   pub tags:        Vec<String>,
28}
29
30impl Manifest {
31   /// Load manifest from fixtures directory
32   pub fn load(fixtures_dir: &Path) -> Result<Self> {
33      let path = fixtures_dir.join("manifest.toml");
34      if !path.exists() {
35         return Ok(Self { fixtures: HashMap::new() });
36      }
37      let content = fs::read_to_string(&path)?;
38      toml::from_str(&content)
39         .map_err(|e| CommitGenError::Other(format!("Failed to parse manifest.toml: {e}")))
40   }
41
42   /// Save manifest to fixtures directory
43   pub fn save(&self, fixtures_dir: &Path) -> Result<()> {
44      let path = fixtures_dir.join("manifest.toml");
45      let content = toml::to_string_pretty(self)
46         .map_err(|e| CommitGenError::Other(format!("Failed to serialize manifest: {e}")))?;
47      fs::write(&path, content)?;
48      Ok(())
49   }
50
51   /// Add a new fixture entry
52   pub fn add(&mut self, name: String, entry: FixtureEntry) {
53      self.fixtures.insert(name, entry);
54   }
55}
56
57/// Metadata for a fixture
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct FixtureMeta {
60   /// Source repository (e.g., "tetra")
61   pub source_repo:   String,
62   /// Original commit hash
63   pub source_commit: String,
64   /// Why this fixture is interesting
65   pub description:   String,
66   /// When this fixture was captured
67   pub captured_at:   String,
68   /// Tags for categorization
69   #[serde(default)]
70   pub tags:          Vec<String>,
71}
72
73/// Context captured for analysis (replaces live git queries)
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct FixtureContext {
76   /// Style patterns from recent commits
77   #[serde(default)]
78   pub recent_commits:  Option<String>,
79   /// Common scopes in repository
80   #[serde(default)]
81   pub common_scopes:   Option<String>,
82   /// Project metadata
83   #[serde(default)]
84   pub project_context: Option<String>,
85   /// User-provided context
86   #[serde(default)]
87   pub user_context:    Option<String>,
88}
89
90/// Input data for a fixture
91#[derive(Debug, Clone)]
92pub struct FixtureInput {
93   /// The diff content
94   pub diff:             String,
95   /// The stat content
96   pub stat:             String,
97   /// Pre-computed scope candidates
98   pub scope_candidates: String,
99   /// Analysis context
100   pub context:          FixtureContext,
101}
102
103/// Golden (expected) output
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct Golden {
106   /// Expected analysis result
107   pub analysis:      ConventionalAnalysis,
108   /// Expected final commit message
109   pub final_message: String,
110}
111
112/// A complete fixture with all data
113#[derive(Debug, Clone)]
114pub struct Fixture {
115   /// Fixture name (directory name)
116   pub name:   String,
117   /// Fixture metadata
118   pub meta:   FixtureMeta,
119   /// Input data
120   pub input:  FixtureInput,
121   /// Golden output (None if not yet generated)
122   pub golden: Option<Golden>,
123}
124
125impl Fixture {
126   /// Load a fixture from disk
127   pub fn load(fixtures_dir: &Path, name: &str) -> Result<Self> {
128      let fixture_dir = fixtures_dir.join(name);
129      if !fixture_dir.exists() {
130         return Err(CommitGenError::Other(format!(
131            "Fixture '{}' not found at {}",
132            name,
133            fixture_dir.display()
134         )));
135      }
136
137      // Load metadata
138      let meta_path = fixture_dir.join("meta.toml");
139      let meta: FixtureMeta = if meta_path.exists() {
140         let content = fs::read_to_string(&meta_path)?;
141         toml::from_str(&content).map_err(|e| {
142            CommitGenError::Other(format!("Failed to parse {}: {e}", meta_path.display()))
143         })?
144      } else {
145         return Err(CommitGenError::Other(format!("Fixture '{name}' missing meta.toml")));
146      };
147
148      // Load input files
149      let input_dir = fixture_dir.join("input");
150      let diff = fs::read_to_string(input_dir.join("diff.patch"))
151         .map_err(|e| CommitGenError::Other(format!("Failed to read diff.patch: {e}")))?;
152      let stat = fs::read_to_string(input_dir.join("stat.txt"))
153         .map_err(|e| CommitGenError::Other(format!("Failed to read stat.txt: {e}")))?;
154      let scope_candidates =
155         fs::read_to_string(input_dir.join("scope_candidates.txt")).unwrap_or_default();
156
157      // Load context
158      let context_path = input_dir.join("context.toml");
159      let context: FixtureContext = if context_path.exists() {
160         let content = fs::read_to_string(&context_path)?;
161         toml::from_str(&content)
162            .map_err(|e| CommitGenError::Other(format!("Failed to parse context.toml: {e}")))?
163      } else {
164         FixtureContext::default()
165      };
166
167      // Load golden output if it exists
168      let golden_dir = fixture_dir.join("golden");
169      let golden = if golden_dir.exists() {
170         let analysis_path = golden_dir.join("analysis.json");
171         let final_path = golden_dir.join("final.txt");
172
173         if analysis_path.exists() && final_path.exists() {
174            let analysis_content = fs::read_to_string(&analysis_path)?;
175            let analysis: ConventionalAnalysis = serde_json::from_str(&analysis_content)
176               .map_err(|e| CommitGenError::Other(format!("Failed to parse analysis.json: {e}")))?;
177            let final_message = fs::read_to_string(&final_path)?;
178            Some(Golden { analysis, final_message })
179         } else {
180            None
181         }
182      } else {
183         None
184      };
185
186      Ok(Self {
187         name: name.to_string(),
188         meta,
189         input: FixtureInput { diff, stat, scope_candidates, context },
190         golden,
191      })
192   }
193
194   /// Save a fixture to disk
195   pub fn save(&self, fixtures_dir: &Path) -> Result<()> {
196      let fixture_dir = fixtures_dir.join(&self.name);
197      let input_dir = fixture_dir.join("input");
198      let golden_dir = fixture_dir.join("golden");
199
200      // Create directories
201      fs::create_dir_all(&input_dir)?;
202      fs::create_dir_all(&golden_dir)?;
203
204      // Save metadata
205      let meta_content = toml::to_string_pretty(&self.meta)
206         .map_err(|e| CommitGenError::Other(format!("Failed to serialize meta: {e}")))?;
207      fs::write(fixture_dir.join("meta.toml"), meta_content)?;
208
209      // Save input files
210      fs::write(input_dir.join("diff.patch"), &self.input.diff)?;
211      fs::write(input_dir.join("stat.txt"), &self.input.stat)?;
212      fs::write(input_dir.join("scope_candidates.txt"), &self.input.scope_candidates)?;
213
214      let context_content = toml::to_string_pretty(&self.input.context)
215         .map_err(|e| CommitGenError::Other(format!("Failed to serialize context: {e}")))?;
216      fs::write(input_dir.join("context.toml"), context_content)?;
217
218      // Save golden output if present
219      if let Some(golden) = &self.golden {
220         let analysis_json = serde_json::to_string_pretty(&golden.analysis)?;
221         fs::write(golden_dir.join("analysis.json"), analysis_json)?;
222         fs::write(golden_dir.join("final.txt"), &golden.final_message)?;
223      }
224
225      Ok(())
226   }
227
228   /// Update golden output
229   pub fn update_golden(&mut self, analysis: ConventionalAnalysis, final_message: String) {
230      self.golden = Some(Golden { analysis, final_message });
231   }
232}
233
234/// Discover all fixtures in a directory
235pub fn discover_fixtures(fixtures_dir: &Path) -> Result<Vec<String>> {
236   let mut fixtures = Vec::new();
237
238   if !fixtures_dir.exists() {
239      return Ok(fixtures);
240   }
241
242   for entry in fs::read_dir(fixtures_dir)? {
243      let entry = entry?;
244      let path = entry.path();
245
246      // Skip manifest.toml and non-directories
247      if !path.is_dir() {
248         continue;
249      }
250
251      // Check if it has meta.toml (valid fixture)
252      if path.join("meta.toml").exists()
253         && let Some(name) = path.file_name().and_then(|n| n.to_str())
254      {
255         fixtures.push(name.to_string());
256      }
257   }
258
259   fixtures.sort();
260   Ok(fixtures)
261}