reinhardt_db/migrations/introspect/
config.rs1use super::naming::NamingConvention;
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11#[non_exhaustive]
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(default)]
15#[derive(Default)]
16pub struct IntrospectConfig {
17 pub database: DatabaseConfig,
19
20 pub output: OutputConfig,
22
23 pub generation: GenerationConfig,
25
26 pub tables: TableFilterConfig,
28
29 #[serde(default)]
31 pub type_overrides: HashMap<String, String>,
32
33 #[serde(default)]
35 pub imports: ImportsConfig,
36}
37
38impl IntrospectConfig {
39 pub fn with_database_url(mut self, url: &str) -> Self {
41 self.database.url = url.to_string();
42 self
43 }
44
45 pub fn with_output_dir(mut self, dir: impl Into<PathBuf>) -> Self {
47 self.output.directory = dir.into();
48 self
49 }
50
51 pub fn with_app_label(mut self, label: &str) -> Self {
53 self.generation.app_label = label.to_string();
54 self
55 }
56
57 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 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 pub fn should_include_table(&self, table_name: &str) -> bool {
80 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 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 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 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#[non_exhaustive]
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(default)]
141#[derive(Default)]
142pub struct DatabaseConfig {
143 pub url: String,
147}
148
149impl DatabaseConfig {
150 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 std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingDatabaseUrl)
160 } else {
161 Ok(self.url.clone())
162 }
163 }
164}
165
166#[non_exhaustive]
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(default)]
170pub struct OutputConfig {
171 pub directory: PathBuf,
173
174 #[serde(default)]
176 pub single_file: bool,
177
178 #[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#[non_exhaustive]
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(default)]
201pub struct GenerationConfig {
202 pub app_label: String,
204
205 #[serde(default = "default_true")]
207 pub detect_relationships: bool,
208
209 #[serde(default = "default_derives")]
211 pub derives: Vec<String>,
212
213 #[serde(default = "default_true")]
215 pub include_column_comments: bool,
216
217 #[serde(default)]
219 pub struct_naming: NamingConventionConfig,
220
221 #[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 field_naming: NamingConventionConfig::SnakeCase,
249 }
250 }
251}
252
253impl GenerationConfig {
254 pub fn struct_naming_convention(&self) -> NamingConvention {
256 self.struct_naming.to_convention()
257 }
258
259 pub fn field_naming_convention(&self) -> NamingConvention {
261 self.field_naming.to_convention()
262 }
263}
264
265#[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 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#[non_exhaustive]
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(default)]
290pub struct TableFilterConfig {
291 pub include: Vec<String>,
293
294 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#[non_exhaustive]
314#[derive(Debug, Clone, Serialize, Deserialize, Default)]
315#[serde(default)]
316pub struct ImportsConfig {
317 pub additional: Vec<String>,
319}
320
321#[non_exhaustive]
323#[derive(Debug, Clone, Default)]
324pub struct CliArgs {
325 pub database_url: Option<String>,
327 pub output_dir: Option<PathBuf>,
329 pub app_label: Option<String>,
331 pub include_tables: Option<String>,
333 pub exclude_tables: Option<String>,
335 pub config_file: Option<PathBuf>,
337 pub dry_run: bool,
339 pub force: bool,
341 pub verbose: bool,
343}
344
345#[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}