Skip to main content

reinhardt_db/migrations/introspect/
config.rs

1//! Configuration for database introspection and code generation.
2//!
3//! Supports TOML configuration files and CLI argument overrides.
4
5use super::naming::NamingConvention;
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11/// Main configuration for database introspection.
12#[non_exhaustive]
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(default)]
15#[derive(Default)]
16pub struct IntrospectConfig {
17	/// Database connection configuration
18	pub database: DatabaseConfig,
19
20	/// Output configuration
21	pub output: OutputConfig,
22
23	/// Code generation configuration
24	pub generation: GenerationConfig,
25
26	/// Table filtering configuration
27	pub tables: TableFilterConfig,
28
29	/// Type overrides: "table.column" -> "RustType"
30	#[serde(default)]
31	pub type_overrides: HashMap<String, String>,
32
33	/// Additional imports configuration
34	#[serde(default)]
35	pub imports: ImportsConfig,
36}
37
38impl IntrospectConfig {
39	/// Create a new configuration with the given database URL.
40	pub fn with_database_url(mut self, url: &str) -> Self {
41		self.database.url = url.to_string();
42		self
43	}
44
45	/// Create a new configuration with the given output directory.
46	pub fn with_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
47		self.output.directory = dir.into();
48		self
49	}
50
51	/// Create a new configuration with the given app label.
52	pub fn with_app_label(mut self, label: &str) -> Self {
53		self.generation.app_label = label.to_string();
54		self
55	}
56
57	/// Load configuration from a TOML file.
58	///
59	/// # Errors
60	///
61	/// Returns error if file cannot be read or parsed.
62	pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
63		let content = std::fs::read_to_string(path.as_ref()).map_err(|e| ConfigError::IoError {
64			path: path.as_ref().to_path_buf(),
65			source: e,
66		})?;
67
68		Self::from_toml(&content)
69	}
70
71	/// Parse configuration from TOML string.
72	pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
73		toml::from_str(content).map_err(|e| ConfigError::ParseError {
74			message: e.to_string(),
75		})
76	}
77
78	/// Check if a table should be included based on filter configuration.
79	pub fn should_include_table(&self, table_name: &str) -> bool {
80		// Check exclude patterns first
81		for pattern in &self.tables.exclude {
82			if let Ok(re) = Regex::new(pattern)
83				&& re.is_match(table_name)
84			{
85				return false;
86			}
87		}
88
89		// Check include patterns
90		if self.tables.include.is_empty() {
91			return true;
92		}
93
94		for pattern in &self.tables.include {
95			if let Ok(re) = Regex::new(pattern)
96				&& re.is_match(table_name)
97			{
98				return true;
99			}
100		}
101
102		false
103	}
104
105	/// Get type override for a specific table.column.
106	pub fn get_type_override(&self, table: &str, column: &str) -> Option<&str> {
107		let key = format!("{}.{}", table, column);
108		self.type_overrides.get(&key).map(|s| s.as_str())
109	}
110
111	/// Merge CLI arguments into configuration.
112	///
113	/// CLI arguments take precedence over config file values.
114	pub fn merge_cli_args(&mut self, args: &CliArgs) {
115		if let Some(ref url) = args.database_url {
116			self.database.url = url.clone();
117		}
118
119		if let Some(ref dir) = args.output_dir {
120			self.output.directory = dir.clone();
121		}
122
123		if let Some(ref label) = args.app_label {
124			self.generation.app_label = label.clone();
125		}
126
127		if let Some(ref pattern) = args.include_tables {
128			self.tables.include = vec![pattern.clone()];
129		}
130
131		if let Some(ref pattern) = args.exclude_tables {
132			self.tables.exclude.push(pattern.clone());
133		}
134	}
135}
136
137/// Database connection configuration.
138#[non_exhaustive]
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(default)]
141#[derive(Default)]
142pub struct DatabaseConfig {
143	/// Database connection URL
144	///
145	/// Can include environment variable reference: "${DATABASE_URL}"
146	pub url: String,
147}
148
149impl DatabaseConfig {
150	/// Resolve the database URL, expanding environment variables.
151	pub fn resolve_url(&self) -> Result<String, ConfigError> {
152		if self.url.starts_with("${") && self.url.ends_with('}') {
153			let var_name = &self.url[2..self.url.len() - 1];
154			std::env::var(var_name).map_err(|_| ConfigError::EnvVarNotFound {
155				name: var_name.to_string(),
156			})
157		} else if self.url.is_empty() {
158			// Try DATABASE_URL environment variable as fallback
159			std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingDatabaseUrl)
160		} else {
161			Ok(self.url.clone())
162		}
163	}
164}
165
166/// Output configuration.
167#[non_exhaustive]
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(default)]
170pub struct OutputConfig {
171	/// Output directory for generated files
172	pub directory: PathBuf,
173
174	/// Generate all models in a single file
175	#[serde(default)]
176	pub single_file: bool,
177
178	/// File name when single_file is true
179	#[serde(default = "default_single_file_name")]
180	pub single_file_name: String,
181}
182
183fn default_single_file_name() -> String {
184	"models.rs".to_string()
185}
186
187impl Default for OutputConfig {
188	fn default() -> Self {
189		Self {
190			directory: PathBuf::from("src/models/generated"),
191			single_file: false,
192			single_file_name: default_single_file_name(),
193		}
194	}
195}
196
197/// Code generation configuration.
198#[non_exhaustive]
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(default)]
201pub struct GenerationConfig {
202	/// App label for generated models
203	pub app_label: String,
204
205	/// Detect relationships from foreign keys
206	#[serde(default = "default_true")]
207	pub detect_relationships: bool,
208
209	/// Derive macros to add to generated structs
210	#[serde(default = "default_derives")]
211	pub derives: Vec<String>,
212
213	/// Include column comments as doc comments
214	#[serde(default = "default_true")]
215	pub include_column_comments: bool,
216
217	/// Naming convention for struct names
218	#[serde(default)]
219	pub struct_naming: NamingConventionConfig,
220
221	/// Naming convention for field names
222	#[serde(default)]
223	pub field_naming: NamingConventionConfig,
224}
225
226fn default_true() -> bool {
227	true
228}
229
230fn default_derives() -> Vec<String> {
231	vec![
232		"Debug".to_string(),
233		"Clone".to_string(),
234		"Serialize".to_string(),
235		"Deserialize".to_string(),
236	]
237}
238
239impl Default for GenerationConfig {
240	fn default() -> Self {
241		Self {
242			app_label: "app".to_string(),
243			detect_relationships: true,
244			derives: default_derives(),
245			include_column_comments: true,
246			struct_naming: NamingConventionConfig::default(),
247			// Rust struct fields should use snake_case by convention
248			field_naming: NamingConventionConfig::SnakeCase,
249		}
250	}
251}
252
253impl GenerationConfig {
254	/// Get the naming convention for struct names.
255	pub fn struct_naming_convention(&self) -> NamingConvention {
256		self.struct_naming.to_convention()
257	}
258
259	/// Get the naming convention for field names.
260	pub fn field_naming_convention(&self) -> NamingConvention {
261		self.field_naming.to_convention()
262	}
263}
264
265/// Naming convention configuration (for serde).
266#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267#[serde(rename_all = "snake_case")]
268pub enum NamingConventionConfig {
269	#[default]
270	PascalCase,
271	SnakeCase,
272	Preserve,
273}
274
275impl NamingConventionConfig {
276	/// Convert to NamingConvention enum.
277	pub fn to_convention(&self) -> NamingConvention {
278		match self {
279			NamingConventionConfig::PascalCase => NamingConvention::PascalCase,
280			NamingConventionConfig::SnakeCase => NamingConvention::SnakeCase,
281			NamingConventionConfig::Preserve => NamingConvention::Preserve,
282		}
283	}
284}
285
286/// Table filtering configuration.
287#[non_exhaustive]
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(default)]
290pub struct TableFilterConfig {
291	/// Include tables matching these patterns (regex)
292	pub include: Vec<String>,
293
294	/// Exclude tables matching these patterns (regex)
295	pub exclude: Vec<String>,
296}
297
298impl Default for TableFilterConfig {
299	fn default() -> Self {
300		Self {
301			include: vec![".*".to_string()],
302			exclude: vec![
303				"^pg_".to_string(),
304				"^reinhardt_migrations".to_string(),
305				"^django_".to_string(),
306				"^auth_".to_string(),
307			],
308		}
309	}
310}
311
312/// Additional imports configuration.
313#[non_exhaustive]
314#[derive(Debug, Clone, Serialize, Deserialize, Default)]
315#[serde(default)]
316pub struct ImportsConfig {
317	/// Additional use statements to include
318	pub additional: Vec<String>,
319}
320
321/// CLI arguments that can override config file values.
322#[non_exhaustive]
323#[derive(Debug, Clone, Default)]
324pub struct CliArgs {
325	/// The database url.
326	pub database_url: Option<String>,
327	/// The output dir.
328	pub output_dir: Option<PathBuf>,
329	/// The app label.
330	pub app_label: Option<String>,
331	/// The include tables.
332	pub include_tables: Option<String>,
333	/// The exclude tables.
334	pub exclude_tables: Option<String>,
335	/// The config file.
336	pub config_file: Option<PathBuf>,
337	/// The dry run.
338	pub dry_run: bool,
339	/// The force.
340	pub force: bool,
341	/// The verbose.
342	pub verbose: bool,
343}
344
345/// Configuration errors.
346#[non_exhaustive]
347#[derive(Debug, thiserror::Error)]
348pub enum ConfigError {
349	#[error("IO error reading {path}: {source}")]
350	IoError {
351		path: PathBuf,
352		#[source]
353		source: std::io::Error,
354	},
355
356	#[error("Failed to parse configuration: {message}")]
357	ParseError { message: String },
358
359	#[error("Environment variable not found: {name}")]
360	EnvVarNotFound { name: String },
361
362	#[error(
363		"Database URL not specified. Set DATABASE_URL environment variable or use --database option"
364	)]
365	MissingDatabaseUrl,
366
367	#[error("Invalid regex pattern: {pattern}")]
368	InvalidPattern { pattern: String },
369}
370
371#[cfg(test)]
372mod tests {
373	use super::*;
374
375	#[test]
376	fn test_default_config() {
377		let config = IntrospectConfig::default();
378
379		assert_eq!(
380			config.output.directory,
381			PathBuf::from("src/models/generated")
382		);
383		assert!(config.generation.detect_relationships);
384		assert!(!config.output.single_file);
385	}
386
387	#[test]
388	fn test_parse_toml_config() {
389		let toml = r#"
390[database]
391url = "postgres://localhost/test"
392
393[output]
394directory = "src/generated"
395
396[generation]
397app_label = "myapp"
398detect_relationships = true
399
400[tables]
401include = ["users", "posts"]
402exclude = ["^pg_"]
403
404[type_overrides]
405"users.status" = "UserStatus"
406"#;
407
408		let config = IntrospectConfig::from_toml(toml).unwrap();
409
410		assert_eq!(config.database.url, "postgres://localhost/test");
411		assert_eq!(config.output.directory, PathBuf::from("src/generated"));
412		assert_eq!(config.generation.app_label, "myapp");
413		assert_eq!(config.tables.include, vec!["users", "posts"]);
414		assert_eq!(
415			config.type_overrides.get("users.status"),
416			Some(&"UserStatus".to_string())
417		);
418	}
419
420	#[test]
421	fn test_table_filtering() {
422		let config = IntrospectConfig {
423			tables: TableFilterConfig {
424				include: vec!["users".to_string(), "posts".to_string()],
425				exclude: vec!["^pg_".to_string()],
426			},
427			..Default::default()
428		};
429
430		assert!(config.should_include_table("users"));
431		assert!(config.should_include_table("posts"));
432		assert!(!config.should_include_table("comments"));
433		assert!(!config.should_include_table("pg_tables"));
434	}
435
436	#[test]
437	fn test_cli_args_merge() {
438		let mut config = IntrospectConfig::default();
439		let args = CliArgs {
440			database_url: Some("postgres://cli/db".to_string()),
441			app_label: Some("cli_app".to_string()),
442			..Default::default()
443		};
444
445		config.merge_cli_args(&args);
446
447		assert_eq!(config.database.url, "postgres://cli/db");
448		assert_eq!(config.generation.app_label, "cli_app");
449	}
450
451	#[test]
452	fn test_builder_pattern() {
453		let config = IntrospectConfig::default()
454			.with_database_url("postgres://localhost/test")
455			.with_output_dir("./output")
456			.with_app_label("test_app");
457
458		assert_eq!(config.database.url, "postgres://localhost/test");
459		assert_eq!(config.output.directory, PathBuf::from("./output"));
460		assert_eq!(config.generation.app_label, "test_app");
461	}
462}