Skip to main content

reinhardt_db/migrations/
autodetector.rs

1//! Migration autodetector
2
3use petgraph::Undirected;
4use petgraph::graph::Graph;
5use petgraph::visit::EdgeRef;
6use regex::Regex;
7use std::collections::{BTreeMap, HashMap};
8use strsim::{jaro_winkler, levenshtein};
9
10use super::model_registry::ManyToManyMetadata;
11
12/// ForeignKey action for ON DELETE and ON UPDATE clauses
13#[derive(
14	Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
15)]
16pub enum ForeignKeyAction {
17	/// Restricts deletion/update (default)
18	Restrict,
19	/// Cascades deletion/update to dependent rows
20	Cascade,
21	/// Sets foreign key to NULL
22	SetNull,
23	/// No action (similar to Restrict but deferred)
24	NoAction,
25	/// Sets foreign key to default value
26	SetDefault,
27}
28
29impl ForeignKeyAction {
30	/// Convert to SQL keyword for use in constraint definitions
31	pub fn to_sql_keyword(&self) -> &'static str {
32		match self {
33			ForeignKeyAction::Restrict => "RESTRICT",
34			ForeignKeyAction::Cascade => "CASCADE",
35			ForeignKeyAction::SetNull => "SET NULL",
36			ForeignKeyAction::NoAction => "NO ACTION",
37			ForeignKeyAction::SetDefault => "SET DEFAULT",
38		}
39	}
40}
41
42impl From<ForeignKeyAction> for reinhardt_query::prelude::ForeignKeyAction {
43	fn from(action: ForeignKeyAction) -> Self {
44		match action {
45			ForeignKeyAction::Restrict => reinhardt_query::prelude::ForeignKeyAction::Restrict,
46			ForeignKeyAction::Cascade => reinhardt_query::prelude::ForeignKeyAction::Cascade,
47			ForeignKeyAction::SetNull => reinhardt_query::prelude::ForeignKeyAction::SetNull,
48			ForeignKeyAction::NoAction => reinhardt_query::prelude::ForeignKeyAction::NoAction,
49			ForeignKeyAction::SetDefault => reinhardt_query::prelude::ForeignKeyAction::SetDefault,
50		}
51	}
52}
53
54impl From<reinhardt_query::prelude::ForeignKeyAction> for ForeignKeyAction {
55	fn from(action: reinhardt_query::prelude::ForeignKeyAction) -> Self {
56		match action {
57			reinhardt_query::prelude::ForeignKeyAction::Restrict => ForeignKeyAction::Restrict,
58			reinhardt_query::prelude::ForeignKeyAction::Cascade => ForeignKeyAction::Cascade,
59			reinhardt_query::prelude::ForeignKeyAction::SetNull => ForeignKeyAction::SetNull,
60			reinhardt_query::prelude::ForeignKeyAction::NoAction => ForeignKeyAction::NoAction,
61			reinhardt_query::prelude::ForeignKeyAction::SetDefault => ForeignKeyAction::SetDefault,
62			// reinhardt-query's ForeignKeyAction is non-exhaustive, so we need a catch-all
63			_ => ForeignKeyAction::NoAction,
64		}
65	}
66}
67
68/// Convert a name to snake_case
69///
70/// Handles:
71/// - Acronyms: inserts underscores at acronym-word boundaries
72/// - Multiple separators: collapses consecutive `_`, `-`, ` `, `.` to single `_`
73/// - Mixed case: properly handles camelCase and PascalCase
74///
75/// # Examples
76///
77/// ```rust,ignore
78/// # use reinhardt_db::migrations::to_snake_case;
79/// assert_eq!(to_snake_case("User"), "user");
80/// assert_eq!(to_snake_case("BlogPost"), "blog_post");
81/// assert_eq!(to_snake_case("HTTPResponse"), "http_response");
82/// assert_eq!(to_snake_case("APIKey"), "api_key");
83/// assert_eq!(to_snake_case("XMLParser"), "xml_parser");
84/// assert_eq!(to_snake_case("User__Profile"), "user_profile");
85/// assert_eq!(to_snake_case("public.users"), "public_users");
86/// ```
87pub use crate::naming::to_snake_case;
88
89/// Convert a snake_case name to PascalCase
90///
91/// Handles multiple separators: `_`, `.`, `-`, space
92///
93/// # Examples
94///
95/// ```rust,ignore
96/// use reinhardt_db::migrations::autodetector::to_pascal_case;
97///
98/// assert_eq!(to_pascal_case("user"), "User");
99/// assert_eq!(to_pascal_case("blog_post"), "BlogPost");
100/// assert_eq!(to_pascal_case("http_response"), "HttpResponse");
101/// assert_eq!(to_pascal_case("following"), "Following");
102/// assert_eq!(to_pascal_case("blocked_users"), "BlockedUsers");
103/// assert_eq!(to_pascal_case("public.users"), "PublicUsers");
104/// ```
105pub fn to_pascal_case(name: &str) -> String {
106	name.split(['_', '.', '-', ' '])
107		.filter(|word| !word.is_empty())
108		.map(|word| {
109			let mut chars = word.chars();
110			match chars.next() {
111				Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
112				None => String::new(),
113			}
114		})
115		.collect()
116}
117
118/// ForeignKey reference information
119#[derive(Debug, Clone, PartialEq)]
120pub struct ForeignKeyInfo {
121	/// Referenced table name
122	pub referenced_table: String,
123	/// Referenced column name (usually "id")
124	pub referenced_column: String,
125	/// ON DELETE action (Cascade, SetNull, Restrict, NoAction, SetDefault)
126	pub on_delete: ForeignKeyAction,
127	/// ON UPDATE action (Cascade, SetNull, Restrict, NoAction, SetDefault)
128	pub on_update: ForeignKeyAction,
129}
130
131/// Field state for migration detection
132#[derive(Debug, Clone)]
133pub struct FieldState {
134	/// The name.
135	pub name: String,
136	/// The field type.
137	pub field_type: super::FieldType,
138	/// The nullable.
139	pub nullable: bool,
140	/// The params.
141	pub params: std::collections::HashMap<String, String>,
142	/// ForeignKey information if this field is a foreign key
143	pub foreign_key: Option<ForeignKeyInfo>,
144}
145
146impl FieldState {
147	/// Creates a new instance.
148	pub fn new(name: impl Into<String>, field_type: super::FieldType, nullable: bool) -> Self {
149		Self {
150			name: name.into(),
151			field_type,
152			nullable,
153			params: std::collections::HashMap::new(),
154			foreign_key: None,
155		}
156	}
157
158	/// Create a new FieldState with ForeignKey information
159	pub fn with_foreign_key(
160		name: impl Into<String>,
161		field_type: super::FieldType,
162		nullable: bool,
163		foreign_key: ForeignKeyInfo,
164	) -> Self {
165		Self {
166			name: name.into(),
167			field_type,
168			nullable,
169			params: std::collections::HashMap::new(),
170			foreign_key: Some(foreign_key),
171		}
172	}
173}
174
175/// Model state for migration detection
176///
177/// Django equivalent: `ModelState` in django/db/migrations/state.py
178#[derive(Debug, Clone)]
179pub struct ModelState {
180	/// Application label (e.g., "auth", "blog")
181	pub app_label: String,
182	/// Model name (e.g., "User", "Post")
183	pub name: String,
184	/// Database table name (e.g., "users", "blog_posts")
185	pub table_name: String,
186	/// Fields: field_name -> FieldState
187	pub fields: std::collections::BTreeMap<String, FieldState>,
188	/// Model options (db_table, ordering, etc.)
189	pub options: std::collections::HashMap<String, String>,
190	/// Base model for inheritance
191	pub base_model: Option<String>,
192	/// Inheritance type: "single_table" or "joined_table"
193	pub inheritance_type: Option<String>,
194	/// Discriminator column for single table inheritance
195	pub discriminator_column: Option<String>,
196	/// Indexes: index_name -> IndexDefinition
197	pub indexes: Vec<IndexDefinition>,
198	/// Constraints: constraint_name -> ConstraintDefinition
199	pub constraints: Vec<ConstraintDefinition>,
200	/// ManyToMany relationships
201	pub many_to_many_fields: Vec<ManyToManyMetadata>,
202}
203
204/// Index definition for a model
205#[derive(Debug, Clone, PartialEq)]
206pub struct IndexDefinition {
207	/// Index name
208	pub name: String,
209	/// Fields to index (in order)
210	pub fields: Vec<String>,
211	/// Whether this is a unique index
212	pub unique: bool,
213}
214
215/// Constraint definition for a model
216#[derive(Debug, Clone, PartialEq)]
217pub struct ConstraintDefinition {
218	/// Constraint name
219	pub name: String,
220	/// Constraint type (e.g., "check", "unique", "foreign_key")
221	pub constraint_type: String,
222	/// Fields involved in the constraint
223	pub fields: Vec<String>,
224	/// Additional constraint expression (e.g., CHECK condition)
225	pub expression: Option<String>,
226	/// ForeignKey-specific information (only for foreign_key type)
227	pub foreign_key_info: Option<ForeignKeyConstraintInfo>,
228}
229
230/// ForeignKey constraint information
231#[derive(Debug, Clone, PartialEq)]
232pub struct ForeignKeyConstraintInfo {
233	/// Referenced table name
234	pub referenced_table: String,
235	/// Referenced columns (usually ["id"])
236	pub referenced_columns: Vec<String>,
237	/// ON DELETE action
238	pub on_delete: ForeignKeyAction,
239	/// ON UPDATE action
240	pub on_update: ForeignKeyAction,
241}
242
243/// Returns true when `c` is a single-column UNIQUE constraint.
244///
245/// "Single-column" means exactly one entry in `fields`; `constraint_type` is
246/// compared case-insensitively against `"unique"` because the codebase has
247/// historically mixed `"unique"` (model registry / autodetector) and
248/// `"UNIQUE"` (`schema_diff::ConstraintSchema`) spellings for the same
249/// concept. The two diff codepaths converge here, so accept either.
250fn is_single_field_unique(c: &ConstraintDefinition) -> bool {
251	c.constraint_type.eq_ignore_ascii_case("unique") && c.fields.len() == 1
252}
253
254/// Extracts the single column name from a constraint SQL of the form
255/// `CONSTRAINT <name> UNIQUE (<column>)` and returns it when the body has no
256/// comma (i.e. it really is a single-column UNIQUE).
257///
258/// Used by `MigrationAutodetector::dedup_redundant_unique_add_constraints`
259/// to identify which `Operation::AddConstraint` operations are eligible for
260/// the redundant-emission check (multi-column UNIQUE / non-UNIQUE
261/// constraints are deliberately ignored).
262fn parse_single_column_unique(constraint_sql: &str) -> Option<&str> {
263	// Uppercase-only match: every emitter in this crate writes `UNIQUE` in
264	// upper case (see `operations::Constraint`'s `Display` impl and
265	// `schema_diff::constraint_schema_to_sql`).
266	let after_unique = constraint_sql.split(" UNIQUE (").nth(1)?;
267	let close = after_unique.find(')')?;
268	let body = after_unique[..close].trim();
269	if body.contains(',') || body.is_empty() {
270		return None;
271	}
272	Some(body)
273}
274
275impl ConstraintDefinition {
276	/// Convert ConstraintDefinition to operations::Constraint
277	pub fn to_constraint(&self) -> super::operations::Constraint {
278		match self.constraint_type.as_str() {
279			"unique" => super::operations::Constraint::Unique {
280				name: self.name.clone(),
281				columns: self.fields.clone(),
282			},
283			"check" => super::operations::Constraint::Check {
284				name: self.name.clone(),
285				expression: self.expression.clone().unwrap_or_default(),
286			},
287			"foreign_key" => {
288				if let Some(fk_info) = &self.foreign_key_info {
289					super::operations::Constraint::ForeignKey {
290						name: self.name.clone(),
291						columns: self.fields.clone(),
292						referenced_table: fk_info.referenced_table.clone(),
293						referenced_columns: fk_info.referenced_columns.clone(),
294						on_delete: fk_info.on_delete,
295						on_update: fk_info.on_update,
296						deferrable: None,
297					}
298				} else {
299					// Fallback if foreign_key_info is missing
300					super::operations::Constraint::ForeignKey {
301						name: self.name.clone(),
302						columns: self.fields.clone(),
303						referenced_table: String::new(),
304						referenced_columns: vec!["id".to_string()],
305						on_delete: ForeignKeyAction::Cascade,
306						on_update: ForeignKeyAction::Cascade,
307						deferrable: None,
308					}
309				}
310			}
311			"one_to_one" => {
312				if let Some(fk_info) = &self.foreign_key_info {
313					super::operations::Constraint::OneToOne {
314						name: self.name.clone(),
315						column: self.fields.first().cloned().unwrap_or_default(),
316						referenced_table: fk_info.referenced_table.clone(),
317						referenced_column: fk_info
318							.referenced_columns
319							.first()
320							.cloned()
321							.unwrap_or_else(|| "id".to_string()),
322						on_delete: fk_info.on_delete,
323						on_update: fk_info.on_update,
324						deferrable: None,
325					}
326				} else {
327					// Fallback
328					super::operations::Constraint::OneToOne {
329						name: self.name.clone(),
330						column: self.fields.first().cloned().unwrap_or_default(),
331						referenced_table: String::new(),
332						referenced_column: "id".to_string(),
333						on_delete: ForeignKeyAction::Cascade,
334						on_update: ForeignKeyAction::Cascade,
335						deferrable: None,
336					}
337				}
338			}
339			_ => {
340				// Default to Check constraint with empty expression
341				super::operations::Constraint::Check {
342					name: self.name.clone(),
343					expression: self.expression.clone().unwrap_or_default(),
344				}
345			}
346		}
347	}
348}
349
350impl ModelState {
351	/// Create a new ModelState with app_label and name
352	///
353	/// # Examples
354	///
355	/// ```rust,ignore
356	/// use reinhardt_db::migrations::ModelState;
357	///
358	/// let model = ModelState::new("myapp", "User");
359	/// assert_eq!(model.app_label, "myapp");
360	/// assert_eq!(model.name, "User");
361	/// assert_eq!(model.table_name, "user");
362	/// assert_eq!(model.fields.len(), 0);
363	/// ```
364	pub fn new(app_label: impl Into<String>, name: impl Into<String>) -> Self {
365		let name_str = name.into();
366		// Convert model name to table name (e.g., "User" -> "user", "BlogPost" -> "blog_post")
367		let table_name = to_snake_case(&name_str);
368
369		Self {
370			app_label: app_label.into(),
371			name: name_str,
372			table_name,
373			fields: std::collections::BTreeMap::new(),
374			options: std::collections::HashMap::new(),
375			base_model: None,
376			inheritance_type: None,
377			discriminator_column: None,
378			indexes: Vec::new(),
379			constraints: Vec::new(),
380			many_to_many_fields: Vec::new(),
381		}
382	}
383
384	/// Add a field to this model
385	///
386	/// # Examples
387	///
388	/// ```rust,ignore
389	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
390	///
391	/// let mut model = ModelState::new("myapp", "User");
392	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
393	/// model.add_field(field);
394	/// assert_eq!(model.fields.len(), 1);
395	/// assert!(model.has_field("email"));
396	/// ```
397	pub fn add_field(&mut self, field: FieldState) {
398		self.fields.insert(field.name.clone(), field);
399	}
400
401	/// Get a field by name
402	///
403	/// # Examples
404	///
405	/// ```rust,ignore
406	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
407	///
408	/// let mut model = ModelState::new("myapp", "User");
409	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
410	/// model.add_field(field);
411	///
412	/// let retrieved = model.get_field("email");
413	/// assert!(retrieved.is_some());
414	/// assert_eq!(retrieved.unwrap().field_type, FieldType::VarChar(255));
415	/// ```
416	pub fn get_field(&self, name: &str) -> Option<&FieldState> {
417		self.fields.get(name)
418	}
419
420	/// Check if a field exists
421	///
422	/// # Examples
423	///
424	/// ```rust,ignore
425	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
426	///
427	/// let mut model = ModelState::new("myapp", "User");
428	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
429	/// model.add_field(field);
430	///
431	/// assert!(model.has_field("email"));
432	/// assert!(!model.has_field("username"));
433	/// ```
434	pub fn has_field(&self, name: &str) -> bool {
435		self.fields.contains_key(name)
436	}
437
438	/// Rename a field
439	///
440	/// # Examples
441	///
442	/// ```rust,ignore
443	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
444	///
445	/// let mut model = ModelState::new("myapp", "User");
446	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
447	/// model.add_field(field);
448	///
449	/// model.rename_field("email", "email_address".to_string());
450	/// assert!(!model.has_field("email"));
451	/// assert!(model.has_field("email_address"));
452	/// ```
453	pub fn rename_field(&mut self, old_name: &str, new_name: String) {
454		if let Some(mut field) = self.fields.remove(old_name) {
455			field.name = new_name.clone();
456			self.fields.insert(new_name, field);
457		}
458	}
459
460	/// Add a constraint to this model
461	///
462	/// # Examples
463	///
464	/// ```rust,ignore
465	/// use reinhardt_db::migrations::{ModelState, ConstraintDefinition};
466	///
467	/// let mut model = ModelState::new("myapp", "User");
468	/// let constraint = ConstraintDefinition {
469	///     name: "unique_email".to_string(),
470	///     constraint_type: "unique".to_string(),
471	///     fields: vec!["email".to_string()],
472	///     expression: None,
473	///     foreign_key_info: None,
474	/// };
475	/// model.add_constraint(constraint);
476	/// assert_eq!(model.constraints.len(), 1);
477	/// ```
478	pub fn add_constraint(&mut self, constraint: ConstraintDefinition) {
479		self.constraints.push(constraint);
480	}
481
482	/// Add a ForeignKey constraint from field information
483	pub fn add_foreign_key_constraint_from_field(&mut self, field_name: &str) {
484		if let Some(field) = self.fields.get(field_name)
485			&& let Some(ref fk_info) = field.foreign_key
486		{
487			let constraint = ConstraintDefinition {
488				name: format!("fk_{}_{}", self.table_name, field_name),
489				constraint_type: "foreign_key".to_string(),
490				fields: vec![field_name.to_string()],
491				expression: None,
492				foreign_key_info: Some(ForeignKeyConstraintInfo {
493					referenced_table: fk_info.referenced_table.clone(),
494					referenced_columns: vec![fk_info.referenced_column.clone()],
495					on_delete: fk_info.on_delete,
496					on_update: fk_info.on_update,
497				}),
498			};
499			self.add_constraint(constraint);
500		}
501	}
502}
503
504/// Project state for migration detection
505///
506/// Django equivalent: `ProjectState` in django/db/migrations/state.py
507///
508/// # Examples
509///
510/// ```rust,ignore
511/// use reinhardt_db::migrations::{ProjectState, ModelState, FieldState, FieldType};
512///
513/// let mut state = ProjectState::new();
514/// let mut model = ModelState::new("myapp", "User");
515/// model.add_field(FieldState::new("id", FieldType::Integer, false));
516/// state.add_model(model);
517///
518/// assert!(state.get_model("myapp", "User").is_some());
519/// ```
520#[derive(Debug, Clone)]
521pub struct ProjectState {
522	/// Models: (app_label, model_name) -> ModelState
523	pub models: std::collections::BTreeMap<(String, String), ModelState>,
524}
525
526impl Default for ProjectState {
527	fn default() -> Self {
528		Self::new()
529	}
530}
531
532impl ProjectState {
533	/// Converts to database schema.
534	pub fn to_database_schema(&self) -> super::schema_diff::DatabaseSchema {
535		let mut tables = BTreeMap::new();
536
537		for ((app_label, model_name), model_state) in &self.models {
538			let mut columns = BTreeMap::new();
539			for (field_name, field_state) in &model_state.fields {
540				// FieldType enum already contains all type information including length
541				// (e.g., VarChar(255), Decimal { precision, scale }). Direct mapping is correct.
542				// Database-specific SQL generation is handled by ColumnTypeDefinition::to_sql_for_dialect.
543				let data_type = field_state.field_type.clone();
544				let nullable = field_state.nullable;
545				let primary_key = field_state
546					.params
547					.get("primary_key")
548					.is_some_and(|s| s == "true");
549				let auto_increment = field_state
550					.params
551					.get("auto_increment")
552					.is_some_and(|s| s == "true");
553				let default = field_state.params.get("default").cloned();
554
555				columns.insert(
556					field_name.clone(),
557					super::schema_diff::ColumnSchema {
558						name: field_name.clone(),
559						data_type,
560						nullable,
561						default,
562						primary_key,
563						auto_increment,
564					},
565				);
566			}
567			// Convert constraints from ModelState to ConstraintSchema
568			let constraints: Vec<super::schema_diff::ConstraintSchema> = model_state
569				.constraints
570				.iter()
571				.map(|c| super::schema_diff::ConstraintSchema {
572					name: c.name.clone(),
573					constraint_type: c.constraint_type.clone(),
574					definition: c.fields.join(", "),
575					foreign_key_info: None,
576				})
577				.collect();
578
579			// Convert indexes from ModelState to IndexSchema
580			let indexes: Vec<super::schema_diff::IndexSchema> = model_state
581				.indexes
582				.iter()
583				.map(|idx| super::schema_diff::IndexSchema {
584					name: idx.name.clone(),
585					columns: idx.fields.clone(),
586					unique: idx.unique,
587				})
588				.collect();
589
590			// Use app_label + model_name as table key to prevent collisions
591			// across apps (Django convention: app_label_modelname)
592			let table_key = format!("{}_{}", app_label, model_name.to_lowercase());
593			tables.insert(
594				table_key,
595				super::schema_diff::TableSchema {
596					name: model_state.table_name.clone(),
597					columns,
598					indexes,
599					constraints,
600				},
601			);
602		}
603
604		super::schema_diff::DatabaseSchema { tables }
605	}
606
607	/// Convert ProjectState to DatabaseSchema for a specific app
608	///
609	/// This method filters models by app_label before converting to DatabaseSchema,
610	/// allowing per-app migration generation.
611	///
612	/// # Examples
613	///
614	/// ```rust,ignore
615	/// use reinhardt_db::migrations::ProjectState;
616	///
617	/// let state = ProjectState::from_global_registry();
618	/// let schema = state.to_database_schema_for_app("users");
619	/// // schema contains only tables for the "users" app
620	/// ```
621	pub fn to_database_schema_for_app(
622		&self,
623		app_label: &str,
624	) -> super::schema_diff::DatabaseSchema {
625		let mut tables = BTreeMap::new();
626
627		for ((this_app_label, model_name), model_state) in &self.models {
628			// Filter by app_label
629			if this_app_label == app_label {
630				let mut columns = BTreeMap::new();
631				for (field_name, field_state) in &model_state.fields {
632					let data_type = field_state.field_type.clone();
633					let nullable = field_state.nullable;
634					let primary_key = field_state
635						.params
636						.get("primary_key")
637						.is_some_and(|s| s == "true");
638					let auto_increment = field_state
639						.params
640						.get("auto_increment")
641						.is_some_and(|s| s == "true");
642					let default = field_state.params.get("default").cloned();
643
644					columns.insert(
645						field_name.clone(),
646						super::schema_diff::ColumnSchema {
647							name: field_name.clone(),
648							data_type,
649							nullable,
650							default,
651							primary_key,
652							auto_increment,
653						},
654					);
655				}
656
657				// Convert constraints from ModelState to ConstraintSchema
658				let constraints: Vec<super::schema_diff::ConstraintSchema> = model_state
659					.constraints
660					.iter()
661					.map(|c| super::schema_diff::ConstraintSchema {
662						name: c.name.clone(),
663						constraint_type: c.constraint_type.clone(),
664						definition: c.fields.join(", "),
665						foreign_key_info: None,
666					})
667					.collect();
668
669				// Convert indexes from ModelState to IndexSchema
670				let indexes: Vec<super::schema_diff::IndexSchema> = model_state
671					.indexes
672					.iter()
673					.map(|idx| super::schema_diff::IndexSchema {
674						name: idx.name.clone(),
675						columns: idx.fields.clone(),
676						unique: idx.unique,
677					})
678					.collect();
679
680				// Use app_label + model_name as table key to prevent collisions
681				// across apps (Django convention: app_label_modelname)
682				let table_key = format!("{}_{}", this_app_label, model_name.to_lowercase());
683				tables.insert(
684					table_key,
685					super::schema_diff::TableSchema {
686						name: model_state.table_name.clone(),
687						columns,
688						indexes,
689						constraints,
690					},
691				);
692			}
693		}
694
695		super::schema_diff::DatabaseSchema { tables }
696	}
697
698	/// Create a new empty ProjectState
699	///
700	/// # Examples
701	///
702	/// ```rust,ignore
703	/// use reinhardt_db::migrations::ProjectState;
704	///
705	/// let state = ProjectState::new();
706	/// assert_eq!(state.models.len(), 0);
707	/// ```
708	pub fn new() -> Self {
709		Self {
710			models: std::collections::BTreeMap::new(),
711		}
712	}
713
714	/// Add a model to this project state
715	///
716	/// # Examples
717	///
718	/// ```rust,ignore
719	/// use reinhardt_db::migrations::{ProjectState, ModelState};
720	///
721	/// let mut state = ProjectState::new();
722	/// let model = ModelState::new("myapp", "User");
723	/// state.add_model(model);
724	///
725	/// assert_eq!(state.models.len(), 1);
726	/// assert!(state.get_model("myapp", "User").is_some());
727	/// ```
728	pub fn add_model(&mut self, model: ModelState) {
729		let key = (model.app_label.clone(), model.name.clone());
730		self.models.insert(key, model);
731	}
732
733	/// Get a model by app_label and model_name
734	///
735	/// # Examples
736	///
737	/// ```rust,ignore
738	/// use reinhardt_db::migrations::{ProjectState, ModelState};
739	///
740	/// let mut state = ProjectState::new();
741	/// let model = ModelState::new("myapp", "User");
742	/// state.add_model(model);
743	///
744	/// let retrieved = state.get_model("myapp", "User");
745	/// assert!(retrieved.is_some());
746	/// assert_eq!(retrieved.unwrap().name, "User");
747	/// ```
748	pub fn get_model(&self, app_label: &str, model_name: &str) -> Option<&ModelState> {
749		self.models
750			.get(&(app_label.to_string(), model_name.to_string()))
751	}
752
753	/// Get a mutable reference to a model
754	///
755	/// # Examples
756	///
757	/// ```rust,ignore
758	/// use reinhardt_db::migrations::{ProjectState, ModelState, FieldState, FieldType};
759	///
760	/// let mut state = ProjectState::new();
761	/// let model = ModelState::new("myapp", "User");
762	/// state.add_model(model);
763	///
764	/// if let Some(model) = state.get_model_mut("myapp", "User") {
765	///     let field = FieldState::new("email", FieldType::VarChar(255), false);
766	///     model.add_field(field);
767	/// }
768	///
769	/// assert!(state.get_model("myapp", "User").unwrap().has_field("email"));
770	/// ```
771	pub fn get_model_mut(&mut self, app_label: &str, model_name: &str) -> Option<&mut ModelState> {
772		self.models
773			.get_mut(&(app_label.to_string(), model_name.to_string()))
774	}
775
776	/// Get primary key field type for a model
777	///
778	/// Returns the field type of the primary key, defaulting to Uuid if not found
779	/// or if the model is not in the state.
780	///
781	/// # Examples
782	///
783	/// ```ignore
784	/// # // This method is private and cannot be called from external code
785	/// use reinhardt_db::migrations::{ProjectState, ModelState, FieldState, FieldType};
786	///
787	/// let mut state = ProjectState::new();
788	/// let mut model = ModelState::new("myapp", "User");
789	/// model.add_field(FieldState::new("id", FieldType::Integer, false));
790	/// state.add_model(model);
791	///
792	/// let pk_type = state.get_primary_key_type("myapp", "User");
793	/// assert_eq!(pk_type, FieldType::Integer);
794	/// ```
795	fn get_primary_key_type(&self, app_label: &str, model_name: &str) -> super::FieldType {
796		// JSON update
797		if let Some(model_state) = self.get_model(app_label, model_name) {
798			// Search the “id” field (by default primary key name)
799			if let Some((_, id_field)) = model_state
800				.fields
801				.iter()
802				.find(|(name, _)| name.as_str() == "id")
803			{
804				return id_field.field_type.clone();
805			}
806
807			// Search fields with the primary_key parameter
808			if let Some((_, pk_field)) = model_state
809				.fields
810				.iter()
811				.find(|(_, f)| f.params.get("primary_key").map(String::as_str) == Some("true"))
812			{
813				return pk_field.field_type.clone();
814			}
815		}
816
817		// If not found in to_state, search the global registry
818		if let Some(model_meta) =
819			super::model_registry::global_registry().get_model(app_label, model_name)
820		{
821			// Search the “id” fields
822			if let Some(id_field) = model_meta.fields.get("id") {
823				return id_field.field_type.clone();
824			}
825
826			// Search fields with the primary_key parameter
827			for field_meta in model_meta.fields.values() {
828				if field_meta.params.get("primary_key").map(String::as_str) == Some("true") {
829					return field_meta.field_type.clone();
830				}
831			}
832		}
833
834		// The default is UUID (current hardcoded value)
835		super::FieldType::Uuid
836	}
837
838	/// Get a model by table name
839	///
840	/// # Examples
841	///
842	/// ```rust,ignore
843	/// use reinhardt_db::migrations::{ProjectState, ModelState};
844	///
845	/// let mut state = ProjectState::new();
846	/// let mut model = ModelState::new("myapp", "User");
847	/// model.table_name = "myapp_user".to_string();
848	/// state.add_model(model);
849	///
850	/// let retrieved = state.get_model_by_table_name("myapp", "myapp_user");
851	/// assert!(retrieved.is_some());
852	/// assert_eq!(retrieved.unwrap().name, "User");
853	/// ```
854	pub fn get_model_by_table_name(
855		&self,
856		app_label: &str,
857		table_name: &str,
858	) -> Option<&ModelState> {
859		self.models
860			.values()
861			.find(|model| model.app_label == app_label && model.table_name == table_name)
862	}
863
864	/// Filter models by app_label and return a new ProjectState containing only those models
865	///
866	/// This method is used to create app-specific ProjectState for per-app migration generation.
867	///
868	/// # Examples
869	///
870	/// ```rust,ignore
871	/// use reinhardt_db::migrations::{ProjectState, ModelState};
872	///
873	/// let mut state = ProjectState::new();
874	/// state.add_model(ModelState::new("users", "User"));
875	/// state.add_model(ModelState::new("users", "Profile"));
876	/// state.add_model(ModelState::new("posts", "Post"));
877	///
878	/// let users_state = state.filter_by_app("users");
879	/// assert_eq!(users_state.models.len(), 2);
880	/// assert!(users_state.get_model("users", "User").is_some());
881	/// assert!(users_state.get_model("users", "Profile").is_some());
882	/// assert!(users_state.get_model("posts", "Post").is_none());
883	/// ```
884	pub fn filter_by_app(&self, app_label: &str) -> Self {
885		let mut filtered = Self::new();
886		for ((app, _model_name), model_state) in &self.models {
887			if app == app_label {
888				filtered.add_model(model_state.clone());
889			}
890		}
891		filtered
892	}
893
894	/// Remove a model from this project state
895	///
896	/// # Examples
897	///
898	/// ```rust,ignore
899	/// use reinhardt_db::migrations::{ProjectState, ModelState};
900	///
901	/// let mut state = ProjectState::new();
902	/// let model = ModelState::new("myapp", "User");
903	/// state.add_model(model);
904	///
905	/// state.remove_model("myapp", "User");
906	/// assert!(state.get_model("myapp", "User").is_none());
907	/// ```
908	pub fn remove_model(&mut self, app_label: &str, model_name: &str) -> Option<ModelState> {
909		self.models
910			.remove(&(app_label.to_string(), model_name.to_string()))
911	}
912
913	/// Rename a model
914	///
915	/// # Examples
916	///
917	/// ```rust,ignore
918	/// use reinhardt_db::migrations::{ProjectState, ModelState};
919	///
920	/// let mut state = ProjectState::new();
921	/// let model = ModelState::new("myapp", "User");
922	/// state.add_model(model);
923	///
924	/// state.rename_model("myapp", "User", "Account".to_string());
925	/// assert!(state.get_model("myapp", "User").is_none());
926	/// assert!(state.get_model("myapp", "Account").is_some());
927	/// ```
928	pub fn rename_model(&mut self, app_label: &str, old_name: &str, new_name: String) {
929		if let Some(mut model) = self
930			.models
931			.remove(&(app_label.to_string(), old_name.to_string()))
932		{
933			model.name = new_name.clone();
934			self.models.insert((app_label.to_string(), new_name), model);
935		}
936	}
937
938	/// Load ProjectState from the global model registry
939	///
940	/// Django equivalent: `ProjectState.from_apps()` in django/db/migrations/state.py:594-600
941	///
942	/// # Examples
943	///
944	/// ```rust,ignore
945	/// use reinhardt_db::migrations::ProjectState;
946	///
947	/// let state = ProjectState::from_global_registry();
948	/// // state will contain all models registered in the global registry
949	/// ```
950	pub fn from_global_registry() -> Self {
951		use super::model_registry::global_registry;
952
953		let registry = global_registry();
954		let models_metadata = registry.get_models();
955
956		let mut state = ProjectState::new();
957		let mut intermediate_tables = Vec::new();
958
959		// First, add all regular models
960		for metadata in &models_metadata {
961			let model_state = metadata.to_model_state();
962			state.add_model(model_state);
963		}
964
965		// Then, generate intermediate tables for ManyToMany relationships
966		for metadata in &models_metadata {
967			for m2m in &metadata.many_to_many_fields {
968				// Generate intermediate table for this ManyToMany relationship
969				let intermediate_table = state.create_intermediate_table_for_m2m(
970					&metadata.app_label,
971					&metadata.model_name,
972					&metadata.table_name,
973					m2m,
974				);
975				intermediate_tables.push(intermediate_table);
976			}
977		}
978
979		// Add all intermediate tables to state
980		for table in intermediate_tables {
981			state.add_model(table);
982		}
983
984		state
985	}
986
987	/// Create an intermediate table ModelState for a ManyToMany relationship
988	///
989	/// This generates a ModelState representing the intermediate/junction table
990	/// for a ManyToMany relationship.
991	///
992	/// # Arguments
993	///
994	/// * `source_app_label` - The app label of the source model (e.g., "auth")
995	/// * `source_model_name` - The name of the source model (e.g., "User")
996	/// * `source_table_name` - The table name of the source model (e.g., "auth_user")
997	/// * `m2m` - ManyToMany relationship metadata
998	///
999	/// # Returns
1000	///
1001	/// A `ModelState` representing the intermediate table with:
1002	/// - Auto-increment primary key `id`
1003	/// - Foreign key to source model: `{source_table}_id` (or
1004	///   `from_{source_table}_id` for self-referencing M2M)
1005	/// - Foreign key to target model: `{target_table}_id` (or
1006	///   `to_{target_table}_id` for self-referencing M2M)
1007	/// - Foreign key constraints with CASCADE
1008	/// - Unique constraint on (source_id, target_id)
1009	fn create_intermediate_table_for_m2m(
1010		&self,
1011		source_app_label: &str,
1012		source_model_name: &str,
1013		source_table_name: &str,
1014		m2m: &super::model_registry::ManyToManyMetadata,
1015	) -> ModelState {
1016		// Default through-table and column names come from the canonical
1017		// convention in `crate::m2m_naming` (also re-exported as
1018		// `crate::migrations::naming`) so this site cannot drift from
1019		// `detect_created_many_to_many` (lookup) or `ManyToManyAccessor`
1020		// (runtime). See issue #4665.
1021		let table_name = m2m.through.clone().unwrap_or_else(|| {
1022			crate::m2m_naming::default_through_table(source_table_name, &m2m.field_name)
1023		});
1024
1025		// Generate model name: PascalCase version of field_name
1026		// Example: "following" -> "UserFollowing"
1027		let model_name = format!("{}{}", source_model_name, to_pascal_case(&m2m.field_name));
1028
1029		let mut model_state = ModelState::new(source_app_label, &model_name);
1030		model_state.table_name = table_name.clone();
1031
1032		// Add primary key field: id
1033		let mut id_field = FieldState::new("id".to_string(), super::FieldType::Integer, false);
1034		id_field
1035			.params
1036			.insert("primary_key".to_string(), "true".to_string());
1037		id_field
1038			.params
1039			.insert("auto_increment".to_string(), "true".to_string());
1040		model_state.add_field(id_field);
1041
1042		// Determine the primary key type for the source and target from the registry
1043		let source_pk_type = self.get_primary_key_type(source_app_label, source_model_name);
1044		// Extract target app_label from to_model (may be in "app.Model" format)
1045		let (target_app, target_model) = if m2m.to_model.contains('.') {
1046			let parts: Vec<&str> = m2m.to_model.split('.').collect();
1047			(parts[0], parts[1])
1048		} else {
1049			(source_app_label, m2m.to_model.as_str())
1050		};
1051
1052		let target_pk_type = self.get_primary_key_type(target_app, target_model);
1053
1054		// Resolve the target table name from ProjectState, falling back to the
1055		// `app_label`-prefixed snake_case form as a last-resort heuristic.
1056		let target_table_name = self
1057			.get_model(target_app, target_model)
1058			.map(|m| m.table_name.clone())
1059			.unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(target_model)));
1060
1061		// Default FK column names come from the canonical convention in
1062		// `crate::m2m_naming::default_m2m_columns` (single source of truth,
1063		// see issue #4665): `{table}_id` for non-self-referential relations,
1064		// and `from_/to_` prefixes when source and target tables match.
1065		// Keying off the *actual* table names (not the struct identifiers)
1066		// matches the ORM accessor fallback in
1067		// `crates/reinhardt-db/src/orm/many_to_many_accessor.rs` and the
1068		// on-disk schema produced by initial migrations (#4659).
1069		let (default_source_col, default_target_col) =
1070			crate::m2m_naming::default_m2m_columns(source_table_name, &target_table_name);
1071		let source_field_name = m2m.source_field.clone().unwrap_or(default_source_col);
1072		let target_field_name = m2m.target_field.clone().unwrap_or(default_target_col);
1073
1074		// Add foreign key to source model
1075		let mut from_field =
1076			FieldState::new(source_field_name.clone(), source_pk_type.clone(), false);
1077		from_field
1078			.params
1079			.insert("not_null".to_string(), "true".to_string());
1080		from_field.foreign_key = Some(ForeignKeyInfo {
1081			referenced_table: source_table_name.to_string(),
1082			referenced_column: "id".to_string(),
1083			on_delete: ForeignKeyAction::Cascade,
1084			on_update: ForeignKeyAction::Cascade,
1085		});
1086		model_state.add_field(from_field);
1087
1088		// Add foreign key to target model
1089		let mut to_field = FieldState::new(target_field_name.clone(), target_pk_type, false);
1090		to_field
1091			.params
1092			.insert("not_null".to_string(), "true".to_string());
1093		to_field.foreign_key = Some(ForeignKeyInfo {
1094			referenced_table: target_table_name,
1095			referenced_column: "id".to_string(),
1096			on_delete: ForeignKeyAction::Cascade,
1097			on_update: ForeignKeyAction::Cascade,
1098		});
1099		model_state.add_field(to_field);
1100
1101		// Add foreign key constraints
1102		model_state.add_foreign_key_constraint_from_field(&source_field_name);
1103		model_state.add_foreign_key_constraint_from_field(&target_field_name);
1104
1105		// Add unique constraint on (from_id, to_id)
1106		let unique_constraint = ConstraintDefinition {
1107			name: format!("{}_unique", table_name),
1108			constraint_type: "unique".to_string(),
1109			fields: vec![source_field_name, target_field_name],
1110			expression: None,
1111			foreign_key_info: None,
1112		};
1113		model_state.constraints.push(unique_constraint);
1114
1115		model_state
1116	}
1117
1118	/// Load ProjectState from a list of migrations
1119	///
1120	/// This method constructs a ProjectState by applying all operations
1121	/// from the provided migrations in order. This is useful for determining
1122	/// what the database schema should look like after applying all migrations.
1123	///
1124	/// # Examples
1125	///
1126	/// ```rust,ignore
1127	/// use reinhardt_db::migrations::{ProjectState, Migration};
1128	///
1129	/// let migrations = vec![/* ... */];
1130	/// let state = ProjectState::from_migrations(&migrations);
1131	/// // state will contain all models as they would exist after applying all migrations
1132	/// ```
1133	pub fn from_migrations(migrations: &[super::migration::Migration]) -> Self {
1134		let mut state = Self::new();
1135		for migration in migrations {
1136			state.apply_migration_operations(&migration.operations, &migration.app_label);
1137		}
1138		state
1139	}
1140
1141	/// Apply migration operations to this project state
1142	///
1143	/// This method processes each operation and updates the ProjectState accordingly.
1144	/// It handles:
1145	/// - CreateTable: Creates a new model
1146	/// - DropTable: Removes a model
1147	/// - AddColumn: Adds a field to a model
1148	/// - DropColumn: Removes a field from a model
1149	/// - AlterColumn: Modifies a field
1150	/// - RenameTable: Renames a model's table
1151	/// - RenameColumn: Renames a field
1152	/// - Other operations are logged but not applied to state
1153	pub fn apply_migration_operations(
1154		&mut self,
1155		operations: &[super::operations::Operation],
1156		app_label: &str,
1157	) {
1158		use super::operations::Operation;
1159
1160		for op in operations {
1161			match op {
1162				Operation::CreateTable { name, columns, .. } => {
1163					// Create a new model from the table definition
1164					// Use the provided app_label instead of hardcoding "auto"
1165					// Convert table name to model name (PascalCase)
1166					let model_name = Self::table_name_to_model_name(name, app_label);
1167					let mut model = ModelState::new(app_label, model_name);
1168					model.table_name = name.to_string();
1169
1170					// Convert columns to fields
1171					for col in columns {
1172						let field = self.column_def_to_field_state(col);
1173						model.add_field(field);
1174					}
1175
1176					self.add_model(model);
1177				}
1178				Operation::DropTable { name } => {
1179					// Find and remove the model with this table name
1180					let keys_to_remove: Vec<_> = self
1181						.models
1182						.iter()
1183						.filter(|(_, model)| model.table_name == *name)
1184						.map(|(key, _)| key.clone())
1185						.collect();
1186
1187					for key in keys_to_remove {
1188						self.models.remove(&key);
1189					}
1190				}
1191				Operation::AddColumn { table, column, .. } => {
1192					// Find the model with this table name and add the field
1193					let field = self.column_def_to_field_state(column);
1194					if let Some(model) = self.find_model_by_table_mut(table) {
1195						model.add_field(field);
1196					}
1197				}
1198				Operation::DropColumn { table, column } => {
1199					// Find the model and remove the field
1200					if let Some(model) = self.find_model_by_table_mut(table) {
1201						model.fields.remove(column);
1202					}
1203				}
1204				Operation::AlterColumn {
1205					table,
1206					column,
1207					new_definition,
1208					..
1209				} => {
1210					// Find the model and update the field
1211					let new_field = self.column_def_to_field_state(new_definition);
1212					// Keep the old field name but update everything else
1213					let mut updated_field = new_field;
1214					updated_field.name = column.to_string();
1215
1216					// If model exists, update the field
1217					if let Some(model) = self.find_model_by_table_mut(table) {
1218						model.fields.insert(column.to_string(), updated_field);
1219					} else {
1220						// If model doesn't exist, create it and add the field
1221						// This handles the case where AlterColumn is used in initial migrations
1222						// before CreateTable (which shouldn't happen, but does in some legacy migrations)
1223						let model_name = Self::table_name_to_model_name(table, app_label);
1224						let mut model = ModelState::new(app_label, model_name);
1225						model.table_name = table.to_string();
1226						model.add_field(updated_field);
1227						self.add_model(model);
1228					}
1229				}
1230				Operation::RenameTable { old_name, new_name } => {
1231					// Find the model with old table name and update it
1232					if let Some(model) = self.find_model_by_table_mut(old_name) {
1233						model.table_name = new_name.to_string();
1234					}
1235				}
1236				Operation::RenameColumn {
1237					table,
1238					old_name,
1239					new_name,
1240				} => {
1241					// Find the model and rename the field
1242					if let Some(model) = self.find_model_by_table_mut(table) {
1243						model.rename_field(old_name, new_name.to_string());
1244					}
1245				}
1246				// Other operations don't affect the schema state in ways we track
1247				_ => {
1248					// Operations like CreateIndex, DropIndex, RunSQL, etc.
1249					// are not currently tracked in ProjectState
1250				}
1251			}
1252		}
1253	}
1254
1255	/// Helper: Find a model by table name (immutable)
1256	pub fn find_model_by_table(&self, table_name: &str) -> Option<&ModelState> {
1257		self.models
1258			.values()
1259			.find(|model| model.table_name == table_name)
1260	}
1261
1262	/// Helper: Find a model by table name (mutable)
1263	pub fn find_model_by_table_mut(&mut self, table_name: &str) -> Option<&mut ModelState> {
1264		self.models
1265			.values_mut()
1266			.find(|model| model.table_name == table_name)
1267	}
1268
1269	/// Helper: Convert table name to model name (PascalCase)
1270	///
1271	/// Examples:
1272	/// - `auth_user` → `User` (with app_label="auth")
1273	/// - `auth_password_reset_token` → `PasswordResetToken`
1274	/// - `dm_message` → `DMMessage`
1275	/// - `dm_room` → `DMRoom`
1276	/// - `profile_profile` → `Profile`
1277	fn table_name_to_model_name(table_name: &str, app_label: &str) -> String {
1278		// Remove app_label prefix if present (e.g., "auth_user" → "user")
1279		let prefix = format!("{}_", app_label);
1280		let name_without_prefix = if table_name.starts_with(&prefix) {
1281			&table_name[prefix.len()..]
1282		} else {
1283			table_name
1284		};
1285
1286		// Convert snake_case to PascalCase
1287		name_without_prefix
1288			.split('_')
1289			.map(|word| {
1290				let mut chars = word.chars();
1291				match chars.next() {
1292					Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1293					None => String::new(),
1294				}
1295			})
1296			.collect()
1297	}
1298
1299	/// Helper: Convert ColumnDefinition to FieldState
1300	fn column_def_to_field_state(&self, col: &super::operations::ColumnDefinition) -> FieldState {
1301		let mut params = std::collections::HashMap::new();
1302
1303		if col.primary_key {
1304			params.insert("primary_key".to_string(), "true".to_string());
1305		}
1306		if col.auto_increment {
1307			params.insert("auto_increment".to_string(), "true".to_string());
1308		}
1309		if col.unique {
1310			params.insert("unique".to_string(), "true".to_string());
1311		}
1312		if let Some(default) = &col.default {
1313			params.insert("default".to_string(), default.to_string());
1314		}
1315
1316		FieldState {
1317			name: col.name.to_string(),
1318			field_type: col.type_definition.clone(),
1319			nullable: !col.not_null,
1320			params,
1321			foreign_key: None,
1322		}
1323	}
1324}
1325
1326/// Configuration for similarity threshold calculation
1327///
1328/// This struct controls how aggressive the autodetector is when matching
1329/// models and fields across apps for rename/move detection.
1330///
1331/// Uses a hybrid similarity metric combining:
1332/// - Jaro-Winkler distance: Best for detecting prefix similarities (e.g., "UserModel" vs "UserProfile")
1333/// - Levenshtein distance: Best for detecting edit operations (e.g., "User" vs "Users")
1334///
1335/// # Examples
1336///
1337/// ```rust,ignore
1338/// use reinhardt_db::migrations::SimilarityConfig;
1339///
1340/// // Default configuration (70% threshold for models, 80% for fields)
1341/// let config = SimilarityConfig::default();
1342/// assert_eq!(config.model_threshold(), 0.7);
1343///
1344/// // Custom conservative configuration (higher threshold = fewer matches)
1345/// let config = SimilarityConfig::new(0.85, 0.90).unwrap();
1346///
1347/// // Liberal configuration (lower threshold = more matches, but more false positives)
1348/// let config = SimilarityConfig::new(0.60, 0.70).unwrap();
1349///
1350/// // Custom with specific algorithm weights
1351/// let config = SimilarityConfig::with_weights(0.75, 0.85, 0.6, 0.4).unwrap();
1352/// ```
1353#[non_exhaustive]
1354#[derive(Debug, Clone)]
1355pub struct SimilarityConfig {
1356	/// Threshold for model similarity (0.45 - 0.95)
1357	/// Higher values mean stricter matching (fewer false positives)
1358	model_threshold: f64,
1359	/// Threshold for field similarity (0.45 - 0.95)
1360	/// Higher values mean stricter matching
1361	field_threshold: f64,
1362	/// Weight for Jaro-Winkler component (0.0 - 1.0, default 0.7)
1363	/// Higher values prioritize prefix matching
1364	jaro_winkler_weight: f64,
1365	/// Weight for Levenshtein component (0.0 - 1.0, default 0.3)
1366	/// Higher values prioritize edit distance
1367	/// Note: jaro_winkler_weight + levenshtein_weight should equal 1.0
1368	levenshtein_weight: f64,
1369}
1370
1371impl SimilarityConfig {
1372	/// Create a new SimilarityConfig with custom thresholds
1373	///
1374	/// # Arguments
1375	///
1376	/// * `model_threshold` - Similarity threshold for model matching (0.45 - 0.95)
1377	/// * `field_threshold` - Similarity threshold for field matching (0.45 - 0.95)
1378	///
1379	/// # Errors
1380	///
1381	/// Returns an error if thresholds are outside the valid range (0.45 - 0.95).
1382	/// Values below 0.45 would produce too many false positives.
1383	/// Values above 0.95 would make matching nearly impossible.
1384	///
1385	/// # Examples
1386	///
1387	/// ```rust,ignore
1388	/// use reinhardt_db::migrations::SimilarityConfig;
1389	///
1390	/// let config = SimilarityConfig::new(0.75, 0.85).unwrap();
1391	/// assert_eq!(config.model_threshold(), 0.75);
1392	/// assert_eq!(config.field_threshold(), 0.85);
1393	///
1394	/// // Invalid threshold (too low)
1395	/// assert!(SimilarityConfig::new(0.4, 0.8).is_err());
1396	///
1397	/// // Invalid threshold (too high)
1398	/// assert!(SimilarityConfig::new(0.96, 0.8).is_err());
1399	/// ```
1400	pub fn new(model_threshold: f64, field_threshold: f64) -> Result<Self, String> {
1401		Self::with_weights(model_threshold, field_threshold, 0.7, 0.3)
1402	}
1403
1404	/// Create a new SimilarityConfig with custom thresholds and algorithm weights
1405	///
1406	/// # Arguments
1407	///
1408	/// * `model_threshold` - Similarity threshold for model matching (0.45 - 0.95)
1409	/// * `field_threshold` - Similarity threshold for field matching (0.45 - 0.95)
1410	/// * `jaro_winkler_weight` - Weight for Jaro-Winkler component (0.0 - 1.0)
1411	/// * `levenshtein_weight` - Weight for Levenshtein component (0.0 - 1.0)
1412	///
1413	/// # Errors
1414	///
1415	/// Returns an error if:
1416	/// - Thresholds are outside the valid range (0.45 - 0.95)
1417	/// - Weights are outside the valid range (0.0 - 1.0)
1418	/// - Weights don't sum to approximately 1.0 (within 0.01 tolerance)
1419	///
1420	/// # Examples
1421	///
1422	/// ```rust,ignore
1423	/// use reinhardt_db::migrations::SimilarityConfig;
1424	///
1425	/// // Prefer Jaro-Winkler for prefix matching
1426	/// let config = SimilarityConfig::with_weights(0.75, 0.85, 0.8, 0.2).unwrap();
1427	///
1428	/// // Prefer Levenshtein for edit distance
1429	/// let config = SimilarityConfig::with_weights(0.75, 0.85, 0.3, 0.7).unwrap();
1430	///
1431	/// // Invalid: weights don't sum to 1.0
1432	/// assert!(SimilarityConfig::with_weights(0.75, 0.85, 0.5, 0.3).is_err());
1433	/// ```
1434	pub fn with_weights(
1435		model_threshold: f64,
1436		field_threshold: f64,
1437		jaro_winkler_weight: f64,
1438		levenshtein_weight: f64,
1439	) -> Result<Self, String> {
1440		// Validate thresholds are in reasonable range
1441		// Minimum 0.45: below this produces too many false positives
1442		// Maximum 0.95: above this makes matching nearly impossible
1443		if !(0.45..=0.95).contains(&model_threshold) {
1444			return Err(format!(
1445				"model_threshold must be between 0.45 and 0.95, got {}",
1446				model_threshold
1447			));
1448		}
1449		if !(0.45..=0.95).contains(&field_threshold) {
1450			return Err(format!(
1451				"field_threshold must be between 0.45 and 0.95, got {}",
1452				field_threshold
1453			));
1454		}
1455
1456		// Validate weights are in valid range
1457		if !(0.0..=1.0).contains(&jaro_winkler_weight) {
1458			return Err(format!(
1459				"jaro_winkler_weight must be between 0.0 and 1.0, got {}",
1460				jaro_winkler_weight
1461			));
1462		}
1463		if !(0.0..=1.0).contains(&levenshtein_weight) {
1464			return Err(format!(
1465				"levenshtein_weight must be between 0.0 and 1.0, got {}",
1466				levenshtein_weight
1467			));
1468		}
1469
1470		// Validate weights sum to approximately 1.0 (allow small floating point errors)
1471		let weight_sum = jaro_winkler_weight + levenshtein_weight;
1472		if (weight_sum - 1.0).abs() > 0.01 {
1473			return Err(format!(
1474				"jaro_winkler_weight + levenshtein_weight must sum to 1.0, got {} + {} = {}",
1475				jaro_winkler_weight, levenshtein_weight, weight_sum
1476			));
1477		}
1478
1479		Ok(Self {
1480			model_threshold,
1481			field_threshold,
1482			jaro_winkler_weight,
1483			levenshtein_weight,
1484		})
1485	}
1486
1487	/// Get the model similarity threshold
1488	pub fn model_threshold(&self) -> f64 {
1489		self.model_threshold
1490	}
1491
1492	/// Get the field similarity threshold
1493	pub fn field_threshold(&self) -> f64 {
1494		self.field_threshold
1495	}
1496}
1497
1498impl Default for SimilarityConfig {
1499	/// Default configuration with balanced thresholds and weights
1500	///
1501	/// - Model threshold: 0.7 (70% similarity required)
1502	/// - Field threshold: 0.8 (80% similarity required)
1503	/// - Jaro-Winkler weight: 0.7 (70% weight for prefix matching)
1504	/// - Levenshtein weight: 0.3 (30% weight for edit distance)
1505	fn default() -> Self {
1506		Self {
1507			model_threshold: 0.7,
1508			field_threshold: 0.8,
1509			jaro_winkler_weight: 0.7,
1510			levenshtein_weight: 0.3,
1511		}
1512	}
1513}
1514
1515/// Migration autodetector
1516///
1517/// Django equivalent: `MigrationAutodetector` in django/db/migrations/autodetector.py
1518///
1519/// Detects schema changes between two ProjectStates and generates migrations.
1520///
1521/// # Examples
1522///
1523/// ```rust,ignore
1524/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
1525///
1526/// let mut from_state = ProjectState::new();
1527/// let mut to_state = ProjectState::new();
1528///
1529/// // Add a new model to to_state
1530/// let mut model = ModelState::new("myapp", "User");
1531/// model.add_field(FieldState::new("id", FieldType::Integer, false));
1532/// to_state.add_model(model);
1533///
1534/// let detector = MigrationAutodetector::new(from_state, to_state);
1535/// let changes = detector.detect_changes();
1536///
1537/// // Should detect the new model creation
1538/// assert_eq!(changes.created_models.len(), 1);
1539/// ```
1540pub struct MigrationAutodetector {
1541	from_state: ProjectState,
1542	to_state: ProjectState,
1543	similarity_config: SimilarityConfig,
1544}
1545
1546/// Type alias for moved model information: (from_app, to_app, model_name, rename_table, old_table, new_table)
1547type MovedModelInfo = (String, String, String, bool, Option<String>, Option<String>);
1548
1549/// Type alias for model match result: ((deleted_app, deleted_model), (created_app, created_model), similarity_score)
1550type ModelMatchResult = ((String, String), (String, String), f64);
1551
1552/// Detected changes between two project states
1553#[derive(Debug, Clone, Default)]
1554pub struct DetectedChanges {
1555	/// Models that were created: (app_label, model_name)
1556	pub created_models: Vec<(String, String)>,
1557	/// Models that were deleted: (app_label, model_name)
1558	pub deleted_models: Vec<(String, String)>,
1559	/// Fields that were added: (app_label, model_name, field_name)
1560	pub added_fields: Vec<(String, String, String)>,
1561	/// Fields that were removed: (app_label, model_name, field_name)
1562	pub removed_fields: Vec<(String, String, String)>,
1563	/// Fields that were altered: (app_label, model_name, field_name)
1564	pub altered_fields: Vec<(String, String, String)>,
1565	/// Models that were renamed: (app_label, old_name, new_name)
1566	pub renamed_models: Vec<(String, String, String)>,
1567	/// Models that were moved between apps: (from_app, to_app, model_name, rename_table, old_table, new_table)
1568	pub moved_models: Vec<MovedModelInfo>,
1569	/// Fields that were renamed: (app_label, model_name, old_name, new_name)
1570	pub renamed_fields: Vec<(String, String, String, String)>,
1571	/// Indexes that were added: (app_label, model_name, IndexDefinition)
1572	pub added_indexes: Vec<(String, String, IndexDefinition)>,
1573	/// Indexes that were removed: (app_label, model_name, index_name)
1574	pub removed_indexes: Vec<(String, String, String)>,
1575	/// Constraints that were added: (app_label, model_name, ConstraintDefinition)
1576	pub added_constraints: Vec<(String, String, ConstraintDefinition)>,
1577	/// Constraints that were removed: (app_label, model_name, constraint_name)
1578	pub removed_constraints: Vec<(String, String, String)>,
1579	/// Composite primary keys added: (app_label, model_name, ConstraintDefinition)
1580	pub added_composite_primary_keys: Vec<(String, String, ConstraintDefinition)>,
1581	/// Composite primary keys removed due to modification (same name, different fields): (app_label, model_name, constraint_name)
1582	pub removed_composite_primary_keys: Vec<(String, String, String)>,
1583	/// Auto-increment sequence resets: (app_label, model_name, column_name, value)
1584	pub auto_increment_resets: Vec<(String, String, String, i64)>,
1585	/// Model dependencies for ordering operations
1586	/// Maps (app_label, model_name) -> `Vec<(dependent_app, dependent_model)>`
1587	/// A model depends on another if it has ForeignKey or ManyToMany fields pointing to it
1588	pub model_dependencies: std::collections::BTreeMap<(String, String), Vec<(String, String)>>,
1589	/// ManyToMany intermediate tables that were created
1590	/// Contains (app_label, source_model, through_table, ManyToManyMetadata)
1591	pub created_many_to_many: Vec<(String, String, String, ManyToManyMetadata)>,
1592}
1593
1594impl DetectedChanges {
1595	/// Order models for migration operations based on dependencies
1596	///
1597	/// Uses topological sort (Kahn's algorithm) to determine the correct order
1598	/// for creating or moving models. This ensures that referenced models are
1599	/// processed before models that reference them.
1600	///
1601	/// # Algorithm: Kahn's Algorithm (Topological Sort)
1602	/// - Time Complexity: O(V + E) where V is models, E is dependencies
1603	/// - Detects circular dependencies and handles them gracefully
1604	/// - Returns models in dependency order (bottom-up)
1605	///
1606	/// # Returns
1607	/// A vector of (app_label, model_name) tuples in dependency order.
1608	/// Models with no dependencies come first, models depending on others come last.
1609	///
1610	/// # Examples
1611	///
1612	/// ```rust,ignore
1613	/// use reinhardt_db::migrations::{DetectedChanges};
1614	/// use std::collections::BTreeMap;
1615	///
1616	/// let mut changes = DetectedChanges::default();
1617	/// changes.created_models.push(("accounts".to_string(), "User".to_string()));
1618	/// changes.created_models.push(("blog".to_string(), "Post".to_string()));
1619	///
1620	/// // Post depends on User
1621	/// let mut deps = BTreeMap::new();
1622	/// deps.insert(
1623	///     ("blog".to_string(), "Post".to_string()),
1624	///     vec![("accounts".to_string(), "User".to_string())],
1625	/// );
1626	/// changes.model_dependencies = deps;
1627	///
1628	/// let ordered = changes.order_models_by_dependency();
1629	/// // User comes before Post
1630	/// assert_eq!(ordered[0], ("accounts".to_string(), "User".to_string()));
1631	/// assert_eq!(ordered[1], ("blog".to_string(), "Post".to_string()));
1632	/// ```
1633	pub fn order_models_by_dependency(&self) -> Vec<(String, String)> {
1634		use std::collections::{HashMap, HashSet, VecDeque};
1635
1636		// Build in-degree map (count of incoming edges)
1637		let mut in_degree: HashMap<(String, String), usize> = HashMap::new();
1638		let mut all_models: HashSet<(String, String)> = HashSet::new();
1639
1640		// Collect all models (both created and dependencies)
1641		for model in &self.created_models {
1642			all_models.insert(model.clone());
1643			in_degree.entry(model.clone()).or_insert(0);
1644		}
1645
1646		for model in &self.moved_models {
1647			let model_key = (model.1.clone(), model.2.clone()); // (to_app, model_name)
1648			all_models.insert(model_key.clone());
1649			in_degree.entry(model_key).or_insert(0);
1650		}
1651
1652		// Build in-degree counts from dependencies
1653		for (dependent, dependencies) in &self.model_dependencies {
1654			for dependency in dependencies {
1655				all_models.insert(dependency.clone());
1656				in_degree.entry(dependency.clone()).or_insert(0);
1657				*in_degree.entry(dependent.clone()).or_insert(0) += 1;
1658			}
1659		}
1660
1661		// Kahn's algorithm: Start with models that have no dependencies
1662		let mut queue: VecDeque<(String, String)> = VecDeque::new();
1663		for model in &all_models {
1664			if in_degree.get(model).copied().unwrap_or(0) == 0 {
1665				queue.push_back(model.clone());
1666			}
1667		}
1668
1669		let mut ordered = Vec::new();
1670
1671		while let Some(model) = queue.pop_front() {
1672			ordered.push(model.clone());
1673
1674			// Reduce in-degree for models that depend on this model
1675			// model_dependencies maps dependent -> dependencies
1676			// So we need to find all models that have `model` in their dependencies
1677			for (dependent, dependencies) in &self.model_dependencies {
1678				if dependencies.contains(&model)
1679					&& let Some(degree) = in_degree.get_mut(dependent)
1680				{
1681					*degree -= 1;
1682					if *degree == 0 {
1683						queue.push_back(dependent.clone());
1684					}
1685				}
1686			}
1687		}
1688
1689		// If not all models are ordered, there's a circular dependency
1690		if ordered.len() < all_models.len() {
1691			// Fall back to original order with a warning
1692			let unordered_models: Vec<_> = all_models
1693				.iter()
1694				.filter(|model| !ordered.contains(model))
1695				.map(|(app, name)| format!("{}.{}", app, name))
1696				.collect();
1697
1698			eprintln!(
1699				"⚠️  Warning: Circular dependency detected in models: [{}]",
1700				unordered_models.join(", ")
1701			);
1702			eprintln!(
1703				"    Falling back to original order. Migration operations may need manual reordering."
1704			);
1705
1706			all_models.into_iter().collect()
1707		} else {
1708			ordered
1709		}
1710	}
1711
1712	/// Check for circular dependencies in model relationships
1713	///
1714	/// Detects cycles in the dependency graph using depth-first search.
1715	///
1716	/// # Returns
1717	/// - `Ok(())` if no circular dependencies exist
1718	/// - `Err(Vec<(String, String)>)` with the cycle path if found
1719	///
1720	/// # Examples
1721	///
1722	/// ```rust,ignore
1723	/// use reinhardt_db::migrations::{DetectedChanges};
1724	/// use std::collections::BTreeMap;
1725	///
1726	/// let mut changes = DetectedChanges::default();
1727	///
1728	/// // Create circular dependency: A -> B -> C -> A
1729	/// let mut deps = BTreeMap::new();
1730	/// deps.insert(
1731	///     ("app".to_string(), "A".to_string()),
1732	///     vec![("app".to_string(), "B".to_string())],
1733	/// );
1734	/// deps.insert(
1735	///     ("app".to_string(), "B".to_string()),
1736	///     vec![("app".to_string(), "C".to_string())],
1737	/// );
1738	/// deps.insert(
1739	///     ("app".to_string(), "C".to_string()),
1740	///     vec![("app".to_string(), "A".to_string())],
1741	/// );
1742	/// changes.model_dependencies = deps;
1743	///
1744	/// assert!(changes.check_circular_dependencies().is_err());
1745	/// ```
1746	pub fn check_circular_dependencies(&self) -> Result<(), Vec<(String, String)>> {
1747		use std::collections::HashSet;
1748
1749		let mut visited: HashSet<(String, String)> = HashSet::new();
1750		let mut rec_stack: HashSet<(String, String)> = HashSet::new();
1751		let mut path: Vec<(String, String)> = Vec::new();
1752
1753		fn dfs(
1754			model: &(String, String),
1755			deps: &BTreeMap<(String, String), Vec<(String, String)>>,
1756			visited: &mut HashSet<(String, String)>,
1757			rec_stack: &mut HashSet<(String, String)>,
1758			path: &mut Vec<(String, String)>,
1759		) -> Option<Vec<(String, String)>> {
1760			visited.insert(model.clone());
1761			rec_stack.insert(model.clone());
1762			path.push(model.clone());
1763
1764			if let Some(dependencies) = deps.get(model) {
1765				for dep in dependencies {
1766					if !visited.contains(dep) {
1767						if let Some(cycle) = dfs(dep, deps, visited, rec_stack, path) {
1768							return Some(cycle);
1769						}
1770					} else if rec_stack.contains(dep) {
1771						// Found cycle
1772						let cycle_start = path.iter().position(|m| m == dep).unwrap();
1773						return Some(path[cycle_start..].to_vec());
1774					}
1775				}
1776			}
1777
1778			path.pop();
1779			rec_stack.remove(model);
1780			None
1781		}
1782
1783		for model in self.model_dependencies.keys() {
1784			if !visited.contains(model)
1785				&& let Some(cycle) = dfs(
1786					model,
1787					&self.model_dependencies,
1788					&mut visited,
1789					&mut rec_stack,
1790					&mut path,
1791				) {
1792				return Err(cycle);
1793			}
1794		}
1795
1796		Ok(())
1797	}
1798
1799	/// Remove operations from DetectedChanges based on OperationRef list
1800	///
1801	/// This method is called when a user rejects an inferred intent during
1802	/// interactive migration detection. It removes the specific operations
1803	/// that the rejected intent was tracking, preventing them from being
1804	/// included in the generated migration.
1805	///
1806	/// # Arguments
1807	///
1808	/// * `refs` - Slice of OperationRef indicating which operations to remove
1809	///
1810	/// # Examples
1811	///
1812	/// ```rust,ignore
1813	/// use reinhardt_db::migrations::{DetectedChanges, OperationRef};
1814	///
1815	/// let mut changes = DetectedChanges::default();
1816	/// changes.renamed_models.push((
1817	///     "blog".to_string(),
1818	///     "Post".to_string(),
1819	///     "BlogPost".to_string(),
1820	/// ));
1821	/// changes.added_fields.push((
1822	///     "blog".to_string(),
1823	///     "BlogPost".to_string(),
1824	///     "slug".to_string(),
1825	/// ));
1826	///
1827	/// // Remove the renamed model operation
1828	/// changes.remove_operations(&[OperationRef::RenamedModel {
1829	///     app_label: "blog".to_string(),
1830	///     old_name: "Post".to_string(),
1831	///     new_name: "BlogPost".to_string(),
1832	/// }]);
1833	///
1834	/// assert!(changes.renamed_models.is_empty());
1835	/// // added_fields is not affected
1836	/// assert_eq!(changes.added_fields.len(), 1);
1837	/// ```
1838	pub fn remove_operations(&mut self, refs: &[OperationRef]) {
1839		for op_ref in refs {
1840			match op_ref {
1841				OperationRef::RenamedModel {
1842					app_label,
1843					old_name,
1844					new_name,
1845				} => {
1846					self.renamed_models.retain(|(app, old, new)| {
1847						!(app == app_label && old == old_name && new == new_name)
1848					});
1849				}
1850				OperationRef::MovedModel {
1851					from_app,
1852					to_app,
1853					model_name,
1854				} => {
1855					// MovedModelInfo is (from_app, to_app, model_name, rename_table, old_table, new_table)
1856					self.moved_models.retain(|info| {
1857						!(&info.0 == from_app && &info.1 == to_app && &info.2 == model_name)
1858					});
1859				}
1860				OperationRef::AddedField {
1861					app_label,
1862					model_name,
1863					field_name,
1864				} => {
1865					self.added_fields.retain(|(app, model, field)| {
1866						!(app == app_label && model == model_name && field == field_name)
1867					});
1868				}
1869				OperationRef::RenamedField {
1870					app_label,
1871					model_name,
1872					old_name,
1873					new_name,
1874				} => {
1875					self.renamed_fields.retain(|(app, model, old, new)| {
1876						!(app == app_label
1877							&& model == model_name
1878							&& old == old_name && new == new_name)
1879					});
1880				}
1881				OperationRef::RemovedField {
1882					app_label,
1883					model_name,
1884					field_name,
1885				} => {
1886					self.removed_fields.retain(|(app, model, field)| {
1887						!(app == app_label && model == model_name && field == field_name)
1888					});
1889				}
1890				OperationRef::AlteredField {
1891					app_label,
1892					model_name,
1893					field_name,
1894				} => {
1895					self.altered_fields.retain(|(app, model, field)| {
1896						!(app == app_label && model == model_name && field == field_name)
1897					});
1898				}
1899				OperationRef::CreatedModel {
1900					app_label,
1901					model_name,
1902				} => {
1903					self.created_models
1904						.retain(|(app, model)| !(app == app_label && model == model_name));
1905				}
1906				OperationRef::DeletedModel {
1907					app_label,
1908					model_name,
1909				} => {
1910					self.deleted_models
1911						.retain(|(app, model)| !(app == app_label && model == model_name));
1912				}
1913			}
1914		}
1915	}
1916}
1917
1918// ============================================================================
1919// Advanced Change Inference System
1920// ============================================================================
1921
1922/// Change history entry for temporal pattern analysis
1923///
1924/// Tracks individual changes with timestamps to identify patterns over time.
1925/// This enables the autodetector to learn from past migrations and make
1926/// better predictions about future changes.
1927///
1928/// # Examples
1929///
1930/// ```rust,ignore
1931/// use reinhardt_db::migrations::autodetector::ChangeHistoryEntry;
1932/// use std::time::SystemTime;
1933///
1934/// let entry = ChangeHistoryEntry {
1935///     timestamp: SystemTime::now(),
1936///     change_type: "RenameModel".to_string(),
1937///     app_label: "blog".to_string(),
1938///     model_name: "Post".to_string(),
1939///     field_name: None,
1940///     old_value: Some("BlogPost".to_string()),
1941///     new_value: Some("Post".to_string()),
1942/// };
1943/// ```
1944#[derive(Debug, Clone)]
1945pub struct ChangeHistoryEntry {
1946	/// When this change occurred
1947	pub timestamp: std::time::SystemTime,
1948	/// Type of change (e.g., "RenameModel", "AddField", "MoveModel")
1949	pub change_type: String,
1950	/// App label of the affected model
1951	pub app_label: String,
1952	/// Model name
1953	pub model_name: String,
1954	/// Field name (if field-level change)
1955	pub field_name: Option<String>,
1956	/// Old value (for renames/alterations)
1957	pub old_value: Option<String>,
1958	/// New value (for renames/alterations)
1959	pub new_value: Option<String>,
1960}
1961
1962/// Pattern frequency for learning from historical changes
1963///
1964/// Tracks how often certain patterns appear to predict future changes.
1965/// For example, if "User -> Account" rename happened 5 times in history,
1966/// similar patterns will get higher confidence scores.
1967#[derive(Debug, Clone)]
1968pub struct PatternFrequency {
1969	/// The pattern being tracked (e.g., "RenameModel:User->Account")
1970	pub pattern: String,
1971	/// Number of times this pattern occurred
1972	pub frequency: usize,
1973	/// Last time this pattern was seen
1974	pub last_seen: std::time::SystemTime,
1975	/// Contexts where this pattern appeared
1976	pub contexts: Vec<String>,
1977}
1978
1979/// Change tracker for temporal pattern analysis
1980///
1981/// Maintains a history of schema changes and analyzes patterns over time
1982/// to improve autodetection accuracy. This implements Django's concept of
1983/// "migration squashing" intelligence - learning which changes commonly
1984/// occur together.
1985///
1986/// # Algorithm: Temporal Pattern Mining
1987/// - Time Complexity: O(n) for insertion, O(n log n) for pattern analysis
1988/// - Space Complexity: O(h) where h is history size
1989/// - Uses sliding window for recent changes (last 100 by default)
1990///
1991/// # Examples
1992///
1993/// ```rust,ignore
1994/// use reinhardt_db::migrations::ChangeTracker;
1995///
1996/// let mut tracker = ChangeTracker::new();
1997///
1998/// // Track a model rename
1999/// tracker.record_model_rename("blog", "BlogPost", "Post");
2000///
2001/// // Track a field addition
2002/// tracker.record_field_addition("blog", "Post", "slug");
2003///
2004/// // Get pattern frequency
2005/// let patterns = tracker.get_frequent_patterns(2); // Min frequency: 2
2006/// ```
2007#[derive(Debug, Clone)]
2008pub struct ChangeTracker {
2009	/// Complete history of changes
2010	history: Vec<ChangeHistoryEntry>,
2011	/// Pattern frequency map
2012	patterns: HashMap<String, PatternFrequency>,
2013	/// Maximum history size (for memory efficiency)
2014	max_history_size: usize,
2015}
2016
2017impl ChangeTracker {
2018	/// Create a new change tracker with default settings
2019	///
2020	/// Default max history size: 1000 entries
2021	pub fn new() -> Self {
2022		Self {
2023			history: Vec::new(),
2024			patterns: HashMap::new(),
2025			max_history_size: 1000,
2026		}
2027	}
2028
2029	/// Create a change tracker with custom history size
2030	pub fn with_capacity(max_size: usize) -> Self {
2031		Self {
2032			history: Vec::with_capacity(max_size),
2033			patterns: HashMap::new(),
2034			max_history_size: max_size,
2035		}
2036	}
2037
2038	/// Record a model rename in the history
2039	///
2040	/// # Arguments
2041	/// * `app_label` - App containing the model
2042	/// * `old_name` - Original model name
2043	/// * `new_name` - New model name
2044	pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
2045		let entry = ChangeHistoryEntry {
2046			timestamp: std::time::SystemTime::now(),
2047			change_type: "RenameModel".to_string(),
2048			app_label: app_label.to_string(),
2049			model_name: new_name.to_string(),
2050			field_name: None,
2051			old_value: Some(old_name.to_string()),
2052			new_value: Some(new_name.to_string()),
2053		};
2054
2055		self.add_entry(entry);
2056		self.update_pattern(
2057			&format!("RenameModel:{}->{}", old_name, new_name),
2058			app_label,
2059		);
2060	}
2061
2062	/// Record a model move between apps
2063	pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
2064		let entry = ChangeHistoryEntry {
2065			timestamp: std::time::SystemTime::now(),
2066			change_type: "MoveModel".to_string(),
2067			app_label: to_app.to_string(),
2068			model_name: model_name.to_string(),
2069			field_name: None,
2070			old_value: Some(from_app.to_string()),
2071			new_value: Some(to_app.to_string()),
2072		};
2073
2074		self.add_entry(entry);
2075		self.update_pattern(
2076			&format!("MoveModel:{}->{}:{}", from_app, to_app, model_name),
2077			to_app,
2078		);
2079	}
2080
2081	/// Record a field addition
2082	pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
2083		let entry = ChangeHistoryEntry {
2084			timestamp: std::time::SystemTime::now(),
2085			change_type: "AddField".to_string(),
2086			app_label: app_label.to_string(),
2087			model_name: model_name.to_string(),
2088			field_name: Some(field_name.to_string()),
2089			old_value: None,
2090			new_value: Some(field_name.to_string()),
2091		};
2092
2093		self.add_entry(entry);
2094		self.update_pattern(
2095			&format!("AddField:{}:{}", model_name, field_name),
2096			app_label,
2097		);
2098	}
2099
2100	/// Record a field rename
2101	pub fn record_field_rename(
2102		&mut self,
2103		app_label: &str,
2104		model_name: &str,
2105		old_name: &str,
2106		new_name: &str,
2107	) {
2108		let entry = ChangeHistoryEntry {
2109			timestamp: std::time::SystemTime::now(),
2110			change_type: "RenameField".to_string(),
2111			app_label: app_label.to_string(),
2112			model_name: model_name.to_string(),
2113			field_name: Some(new_name.to_string()),
2114			old_value: Some(old_name.to_string()),
2115			new_value: Some(new_name.to_string()),
2116		};
2117
2118		self.add_entry(entry);
2119		self.update_pattern(
2120			&format!("RenameField:{}:{}->{}", model_name, old_name, new_name),
2121			app_label,
2122		);
2123	}
2124
2125	/// Add an entry to history with size management
2126	fn add_entry(&mut self, entry: ChangeHistoryEntry) {
2127		self.history.push(entry);
2128
2129		// Maintain max history size
2130		if self.history.len() > self.max_history_size {
2131			self.history.remove(0);
2132		}
2133	}
2134
2135	/// Update pattern frequency
2136	fn update_pattern(&mut self, pattern: &str, context: &str) {
2137		self.patterns
2138			.entry(pattern.to_string())
2139			.and_modify(|pf| {
2140				pf.frequency += 1;
2141				pf.last_seen = std::time::SystemTime::now();
2142				if !pf.contexts.contains(&context.to_string()) {
2143					pf.contexts.push(context.to_string());
2144				}
2145			})
2146			.or_insert(PatternFrequency {
2147				pattern: pattern.to_string(),
2148				frequency: 1,
2149				last_seen: std::time::SystemTime::now(),
2150				contexts: vec![context.to_string()],
2151			});
2152	}
2153
2154	/// Get patterns that occur at least `min_frequency` times
2155	///
2156	/// Returns patterns sorted by frequency (descending)
2157	pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
2158		let mut patterns: Vec<_> = self
2159			.patterns
2160			.values()
2161			.filter(|p| p.frequency >= min_frequency)
2162			.cloned()
2163			.collect();
2164
2165		patterns.sort_by(|a, b| b.frequency.cmp(&a.frequency));
2166		patterns
2167	}
2168
2169	/// Get recent changes within the specified duration
2170	///
2171	/// # Arguments
2172	/// * `duration` - Time window (e.g., Duration::from_secs(3600) for last hour)
2173	pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
2174		let now = std::time::SystemTime::now();
2175		self.history
2176			.iter()
2177			.filter(|entry| {
2178				now.duration_since(entry.timestamp)
2179					.map(|d| d < duration)
2180					.unwrap_or(false)
2181			})
2182			.collect()
2183	}
2184
2185	/// Analyze co-occurring patterns
2186	///
2187	/// Returns pairs of patterns that frequently appear together
2188	/// within a time window (default: 1 hour)
2189	pub fn analyze_cooccurrence(
2190		&self,
2191		window: std::time::Duration,
2192	) -> HashMap<(String, String), usize> {
2193		let mut cooccurrences = HashMap::new();
2194
2195		for i in 0..self.history.len() {
2196			for j in (i + 1)..self.history.len() {
2197				if let Ok(diff) = self.history[j]
2198					.timestamp
2199					.duration_since(self.history[i].timestamp)
2200					&& diff <= window
2201				{
2202					let pattern1 = format!(
2203						"{}:{}",
2204						self.history[i].change_type, self.history[i].model_name
2205					);
2206					let pattern2 = format!(
2207						"{}:{}",
2208						self.history[j].change_type, self.history[j].model_name
2209					);
2210					let key = if pattern1 < pattern2 {
2211						(pattern1, pattern2)
2212					} else {
2213						(pattern2, pattern1)
2214					};
2215					*cooccurrences.entry(key).or_insert(0) += 1;
2216				}
2217			}
2218		}
2219
2220		cooccurrences
2221	}
2222
2223	/// Clear all history (useful for testing)
2224	pub fn clear(&mut self) {
2225		self.history.clear();
2226		self.patterns.clear();
2227	}
2228
2229	/// Get total number of changes tracked
2230	pub fn len(&self) -> usize {
2231		self.history.len()
2232	}
2233
2234	/// Check if history is empty
2235	pub fn is_empty(&self) -> bool {
2236		self.history.is_empty()
2237	}
2238}
2239
2240impl Default for ChangeTracker {
2241	fn default() -> Self {
2242		Self::new()
2243	}
2244}
2245
2246/// Pattern match result
2247///
2248/// Represents a single match found by the PatternMatcher.
2249#[derive(Debug, Clone)]
2250pub struct PatternMatch {
2251	/// The pattern that matched
2252	pub pattern: String,
2253	/// Starting position in the text
2254	pub start: usize,
2255	/// Ending position in the text
2256	pub end: usize,
2257	/// The matched text
2258	pub matched_text: String,
2259}
2260
2261/// Pattern matcher using Aho-Corasick algorithm
2262///
2263/// Efficiently searches for multiple patterns simultaneously in model/field names.
2264/// This is useful for detecting common naming patterns like:
2265/// - "User" -> "Account" conversions
2266/// - "created_at" -> "timestamp" renames
2267/// - Common prefix/suffix patterns
2268///
2269/// # Algorithm: Aho-Corasick
2270/// - Time Complexity: O(n + m + z) where n=text length, m=total pattern length, z=matches
2271/// - Space Complexity: O(m) for the automaton
2272/// - Advantage: Simultaneous multi-pattern matching in linear time
2273///
2274/// # Examples
2275///
2276/// ```rust,ignore
2277/// use reinhardt_db::migrations::PatternMatcher;
2278///
2279/// let mut matcher = PatternMatcher::new();
2280/// matcher.add_pattern("User");
2281/// matcher.add_pattern("Post");
2282/// matcher.build();
2283///
2284/// let matches = matcher.find_all("User has many Posts");
2285/// assert_eq!(matches.len(), 2);
2286/// ```
2287#[derive(Debug, Clone)]
2288pub struct PatternMatcher {
2289	/// Patterns to search for
2290	patterns: Vec<String>,
2291	/// Aho-Corasick automaton (built lazily)
2292	automaton: Option<aho_corasick::AhoCorasick>,
2293}
2294
2295impl PatternMatcher {
2296	/// Create a new empty pattern matcher
2297	pub fn new() -> Self {
2298		Self {
2299			patterns: Vec::new(),
2300			automaton: None,
2301		}
2302	}
2303
2304	/// Add a pattern to search for
2305	///
2306	/// Patterns are case-sensitive by default.
2307	/// Call `build()` after adding all patterns.
2308	pub fn add_pattern(&mut self, pattern: &str) {
2309		self.patterns.push(pattern.to_string());
2310		// Invalidate automaton - needs rebuild
2311		self.automaton = None;
2312	}
2313
2314	/// Add multiple patterns at once
2315	pub fn add_patterns<I, S>(&mut self, patterns: I)
2316	where
2317		I: IntoIterator<Item = S>,
2318		S: AsRef<str>,
2319	{
2320		for pattern in patterns {
2321			self.patterns.push(pattern.as_ref().to_string());
2322		}
2323		self.automaton = None;
2324	}
2325
2326	/// Build the Aho-Corasick automaton
2327	///
2328	/// Must be called after adding patterns and before searching.
2329	/// Returns Err if patterns is empty or build fails.
2330	pub fn build(&mut self) -> Result<(), String> {
2331		if self.patterns.is_empty() {
2332			return Err("No patterns to build automaton".to_string());
2333		}
2334
2335		self.automaton = Some(
2336			aho_corasick::AhoCorasick::new(&self.patterns)
2337				.map_err(|e| format!("Failed to build Aho-Corasick automaton: {}", e))?,
2338		);
2339
2340		Ok(())
2341	}
2342
2343	/// Find all pattern matches in the given text
2344	///
2345	/// Returns empty vector if no matches found or automaton not built.
2346	pub fn find_all(&self, text: &str) -> Vec<PatternMatch> {
2347		let Some(ref automaton) = self.automaton else {
2348			return Vec::new();
2349		};
2350
2351		automaton
2352			.find_iter(text)
2353			.map(|mat| PatternMatch {
2354				pattern: self.patterns[mat.pattern().as_usize()].clone(),
2355				start: mat.start(),
2356				end: mat.end(),
2357				matched_text: text[mat.start()..mat.end()].to_string(),
2358			})
2359			.collect()
2360	}
2361
2362	/// Check if any pattern matches the text
2363	pub fn contains_any(&self, text: &str) -> bool {
2364		self.automaton
2365			.as_ref()
2366			.map(|ac| ac.is_match(text))
2367			.unwrap_or(false)
2368	}
2369
2370	/// Find the first match in the text
2371	pub fn find_first(&self, text: &str) -> Option<PatternMatch> {
2372		let automaton = self.automaton.as_ref()?;
2373		let mat = automaton.find(text)?;
2374
2375		Some(PatternMatch {
2376			pattern: self.patterns[mat.pattern().as_usize()].clone(),
2377			start: mat.start(),
2378			end: mat.end(),
2379			matched_text: text[mat.start()..mat.end()].to_string(),
2380		})
2381	}
2382
2383	/// Replace all pattern matches with replacements
2384	///
2385	/// # Arguments
2386	/// * `text` - The text to search in
2387	/// * `replacements` - Map from pattern to replacement string
2388	///
2389	/// # Returns
2390	/// Modified text with all patterns replaced
2391	pub fn replace_all(&self, text: &str, replacements: &HashMap<String, String>) -> String {
2392		let Some(ref automaton) = self.automaton else {
2393			return text.to_string();
2394		};
2395
2396		let mut result = String::new();
2397		let mut last_end = 0;
2398
2399		for mat in automaton.find_iter(text) {
2400			// Add text before match
2401			result.push_str(&text[last_end..mat.start()]);
2402
2403			// Add replacement or original if no replacement found
2404			let pattern = &self.patterns[mat.pattern().as_usize()];
2405			if let Some(replacement) = replacements.get(pattern) {
2406				result.push_str(replacement);
2407			} else {
2408				result.push_str(&text[mat.start()..mat.end()]);
2409			}
2410
2411			last_end = mat.end();
2412		}
2413
2414		// Add remaining text
2415		result.push_str(&text[last_end..]);
2416		result
2417	}
2418
2419	/// Get all patterns currently registered
2420	pub fn patterns(&self) -> &[String] {
2421		&self.patterns
2422	}
2423
2424	/// Clear all patterns
2425	pub fn clear(&mut self) {
2426		self.patterns.clear();
2427		self.automaton = None;
2428	}
2429
2430	/// Check if automaton is built and ready
2431	pub fn is_built(&self) -> bool {
2432		self.automaton.is_some()
2433	}
2434}
2435
2436impl Default for PatternMatcher {
2437	fn default() -> Self {
2438		Self::new()
2439	}
2440}
2441
2442// ============================================================================
2443// Inference Types
2444// ============================================================================
2445
2446/// Condition for an inference rule
2447#[derive(Debug, Clone, PartialEq)]
2448pub enum RuleCondition {
2449	/// Model rename pattern
2450	ModelRename {
2451		/// The source model name pattern.
2452		from_pattern: String,
2453		/// The target model name pattern.
2454		to_pattern: String,
2455	},
2456	/// Model move pattern
2457	ModelMove {
2458		/// The application label pattern.
2459		app_pattern: String,
2460	},
2461	/// Field addition pattern
2462	FieldAddition {
2463		/// The field name pattern.
2464		field_name_pattern: String,
2465	},
2466	/// Field rename pattern
2467	FieldRename {
2468		/// The source field name pattern.
2469		from_pattern: String,
2470		/// The target field name pattern.
2471		to_pattern: String,
2472	},
2473	/// Multiple model renames
2474	MultipleModelRenames {
2475		/// The minimum count of renames.
2476		min_count: usize,
2477	},
2478	/// Multiple field additions
2479	MultipleFieldAdditions {
2480		/// The model name pattern.
2481		model_pattern: String,
2482		/// The minimum count of additions.
2483		min_count: usize,
2484	},
2485}
2486
2487/// Reference to a specific operation in DetectedChanges
2488///
2489/// Used to track which operations an inferred intent relates to,
2490/// enabling removal of operations when the user rejects an intent.
2491#[derive(Debug, Clone, PartialEq)]
2492pub enum OperationRef {
2493	/// Reference to a renamed model: (app_label, old_name, new_name)
2494	RenamedModel {
2495		/// The app label.
2496		app_label: String,
2497		/// The old name.
2498		old_name: String,
2499		/// The new name.
2500		new_name: String,
2501	},
2502	/// Reference to a moved model: (from_app, to_app, model_name)
2503	MovedModel {
2504		/// The from app.
2505		from_app: String,
2506		/// The to app.
2507		to_app: String,
2508		/// The model name.
2509		model_name: String,
2510	},
2511	/// Reference to an added field: (app_label, model_name, field_name)
2512	AddedField {
2513		/// The app label.
2514		app_label: String,
2515		/// The model name.
2516		model_name: String,
2517		/// The field name.
2518		field_name: String,
2519	},
2520	/// Reference to a renamed field: (app_label, model_name, old_name, new_name)
2521	RenamedField {
2522		/// The app label.
2523		app_label: String,
2524		/// The model name.
2525		model_name: String,
2526		/// The old name.
2527		old_name: String,
2528		/// The new name.
2529		new_name: String,
2530	},
2531	/// Reference to a removed field: (app_label, model_name, field_name)
2532	RemovedField {
2533		/// The app label.
2534		app_label: String,
2535		/// The model name.
2536		model_name: String,
2537		/// The field name.
2538		field_name: String,
2539	},
2540	/// Reference to an altered field: (app_label, model_name, field_name)
2541	AlteredField {
2542		/// The app label.
2543		app_label: String,
2544		/// The model name.
2545		model_name: String,
2546		/// The field name.
2547		field_name: String,
2548	},
2549	/// Reference to a created model: (app_label, model_name)
2550	CreatedModel {
2551		/// The app label.
2552		app_label: String,
2553		/// The model name.
2554		model_name: String,
2555	},
2556	/// Reference to a deleted model: (app_label, model_name)
2557	DeletedModel {
2558		/// The app label.
2559		app_label: String,
2560		/// The model name.
2561		model_name: String,
2562	},
2563}
2564
2565/// Inferred intent from detected changes
2566#[derive(Debug, Clone, PartialEq)]
2567pub struct InferredIntent {
2568	/// Type of intent (e.g., "Refactoring", "Add timestamp tracking")
2569	pub intent_type: String,
2570	/// Confidence score (0.0 - 1.0)
2571	pub confidence: f64,
2572	/// Human-readable description
2573	pub description: String,
2574	/// Evidence supporting this intent
2575	pub evidence: Vec<String>,
2576	/// References to operations in DetectedChanges that this intent relates to
2577	///
2578	/// When the user rejects this intent, these operations will be removed
2579	/// from DetectedChanges to prevent migration generation.
2580	pub related_operations: Vec<OperationRef>,
2581}
2582
2583/// Rule for inferring intent from change patterns
2584#[derive(Debug, Clone)]
2585pub struct InferenceRule {
2586	/// Rule name
2587	pub name: String,
2588	/// Required conditions (all must match)
2589	pub conditions: Vec<RuleCondition>,
2590	/// Optional conditions (boost confidence if matched)
2591	pub optional_conditions: Vec<RuleCondition>,
2592	/// Intent type to infer
2593	pub intent_type: String,
2594	/// Base confidence (0.0 - 1.0)
2595	pub base_confidence: f64,
2596	/// Confidence boost per matched optional condition
2597	pub confidence_boost_per_optional: f64,
2598}
2599
2600/// Inference engine for detecting composite change intents
2601///
2602/// Analyzes multiple detected changes to infer high-level intentions.
2603/// For example:
2604/// - AddIndex + AlterField(to larger type) → Performance optimization
2605/// - RenameModel + AddForeignKey → Relationship refactoring
2606/// - AddField + RemoveField → Data migration
2607///
2608/// # Algorithm: Rule-Based Inference
2609/// - Matches detected changes against predefined rules
2610/// - Calculates confidence scores based on pattern matching
2611/// - Returns ranked list of possible intents
2612///
2613/// # Examples
2614///
2615/// ```rust,no_run
2616/// use reinhardt_db::migrations::InferenceEngine;
2617///
2618/// let mut engine = InferenceEngine::new();
2619/// engine.add_default_rules();
2620///
2621/// // Analyze changes with proper arguments
2622/// let model_renames = vec![];
2623/// let model_moves = vec![];
2624/// let field_additions = vec![
2625///     ("users".to_string(), "User".to_string(), "email".to_string())
2626/// ];
2627/// let field_renames = vec![];
2628///
2629/// let intents = engine.infer_intents(
2630///     &model_renames,
2631///     &model_moves,
2632///     &field_additions,
2633///     &field_renames
2634/// );
2635/// ```
2636#[derive(Debug, Clone)]
2637pub struct InferenceEngine {
2638	/// Inference rules
2639	rules: Vec<InferenceRule>,
2640	/// Change history for contextual analysis
2641	///
2642	/// The change tracker maintains a history of schema changes and can be used
2643	/// to improve inference accuracy by analyzing temporal patterns. To use:
2644	///
2645	/// 1. Record changes via `record_model_rename()`, `record_field_addition()`, etc.
2646	/// 2. Query patterns via `get_frequent_patterns()` or `analyze_cooccurrence()`
2647	/// 3. Use pattern analysis to boost confidence scores in inference rules
2648	///
2649	/// Example:
2650	/// ```rust,ignore
2651	/// use reinhardt_db::migrations::autodetector::InferenceEngine;
2652	/// let mut engine = InferenceEngine::new();
2653	/// // Record rename and field addition history
2654	/// engine.record_model_rename("blog", "BlogPost", "Post");
2655	/// engine.record_field_addition("blog", "Post", "slug");
2656	/// // Analyze co-occurrence within a 60-second window
2657	/// let _cooccurrences = engine.analyze_cooccurrence(std::time::Duration::from_secs(60));
2658	/// ```
2659	change_tracker: ChangeTracker,
2660}
2661
2662impl Default for InferenceEngine {
2663	fn default() -> Self {
2664		Self::new()
2665	}
2666}
2667
2668impl InferenceEngine {
2669	/// Create a new inference engine
2670	pub fn new() -> Self {
2671		Self {
2672			rules: Vec::new(),
2673			change_tracker: ChangeTracker::new(),
2674		}
2675	}
2676
2677	/// Add a rule to the engine
2678	pub fn add_rule(&mut self, rule: InferenceRule) {
2679		self.rules.push(rule);
2680	}
2681
2682	/// Add default inference rules
2683	pub fn add_default_rules(&mut self) {
2684		// Rule 1: Model refactoring (rename)
2685		self.add_rule(InferenceRule {
2686			name: "model_refactoring".to_string(),
2687			conditions: vec![RuleCondition::ModelRename {
2688				from_pattern: ".*".to_string(),
2689				to_pattern: ".*".to_string(),
2690			}],
2691			optional_conditions: vec![RuleCondition::MultipleModelRenames { min_count: 2 }],
2692			intent_type: "Refactoring: Model rename".to_string(),
2693			base_confidence: 0.7,
2694			confidence_boost_per_optional: 0.1,
2695		});
2696
2697		// Rule 2: Timestamp tracking
2698		self.add_rule(InferenceRule {
2699			name: "add_timestamp_tracking".to_string(),
2700			conditions: vec![RuleCondition::FieldAddition {
2701				field_name_pattern: "created_at".to_string(),
2702			}],
2703			optional_conditions: vec![RuleCondition::FieldAddition {
2704				field_name_pattern: "updated_at".to_string(),
2705			}],
2706			intent_type: "Add timestamp tracking".to_string(),
2707			base_confidence: 0.8,
2708			confidence_boost_per_optional: 0.15,
2709		});
2710
2711		// Rule 3: Cross-app model move
2712		self.add_rule(InferenceRule {
2713			name: "cross_app_move".to_string(),
2714			conditions: vec![RuleCondition::ModelMove {
2715				app_pattern: ".*".to_string(),
2716			}],
2717			optional_conditions: vec![],
2718			intent_type: "Cross-app model organization".to_string(),
2719			base_confidence: 0.75,
2720			confidence_boost_per_optional: 0.0,
2721		});
2722
2723		// Rule 4: Field refactoring (rename)
2724		self.add_rule(InferenceRule {
2725			name: "field_refactoring".to_string(),
2726			conditions: vec![RuleCondition::FieldRename {
2727				from_pattern: ".*".to_string(),
2728				to_pattern: ".*".to_string(),
2729			}],
2730			optional_conditions: vec![RuleCondition::MultipleFieldAdditions {
2731				model_pattern: ".*".to_string(),
2732				min_count: 2,
2733			}],
2734			intent_type: "Refactoring: Field rename".to_string(),
2735			base_confidence: 0.65,
2736			confidence_boost_per_optional: 0.1,
2737		});
2738
2739		// Rule 5: Model normalization
2740		self.add_rule(InferenceRule {
2741			name: "model_normalization".to_string(),
2742			conditions: vec![RuleCondition::MultipleFieldAdditions {
2743				model_pattern: ".*".to_string(),
2744				min_count: 3,
2745			}],
2746			optional_conditions: vec![],
2747			intent_type: "Schema normalization".to_string(),
2748			base_confidence: 0.6,
2749			confidence_boost_per_optional: 0.0,
2750		});
2751	}
2752
2753	/// Match string against a pattern (supports regex)
2754	///
2755	/// Patterns can be:
2756	/// - Literal strings (exact match)
2757	/// - ".*" wildcard (matches anything)
2758	/// - Regular expressions (e.g., "User.*" matches "User", "UserProfile", etc.)
2759	fn matches_pattern(value: &str, pattern: &str) -> bool {
2760		// Wildcard pattern matches everything
2761		if pattern == ".*" {
2762			return true;
2763		}
2764
2765		// Try exact match first
2766		if value == pattern {
2767			return true;
2768		}
2769
2770		// Try regex match
2771		if let Ok(re) = Regex::new(pattern) {
2772			re.is_match(value)
2773		} else {
2774			// If regex is invalid, fall back to exact match
2775			false
2776		}
2777	}
2778
2779	/// Get all rules
2780	pub fn rules(&self) -> &[InferenceRule] {
2781		&self.rules
2782	}
2783
2784	/// Infer intents from detected changes
2785	pub fn infer_intents(
2786		&self,
2787		model_renames: &[(String, String, String, String)], // (from_app, from_model, to_app, to_model)
2788		model_moves: &[(String, String, String, String)],   // (from_app, from_model, to_app, to_model)
2789		field_additions: &[(String, String, String)],       // (app, model, field)
2790		field_renames: &[(String, String, String, String)], // (app, model, from_field, to_field)
2791	) -> Vec<InferredIntent> {
2792		let mut intents = Vec::new();
2793
2794		for rule in &self.rules {
2795			let mut matches_required = true;
2796			let mut optional_matches = 0;
2797			let mut evidence = Vec::new();
2798
2799			// Check required conditions
2800			for condition in &rule.conditions {
2801				match condition {
2802					RuleCondition::ModelRename {
2803						from_pattern,
2804						to_pattern,
2805					} => {
2806						if model_renames.is_empty() {
2807							matches_required = false;
2808							break;
2809						}
2810
2811						// Check if any model rename matches the patterns
2812						let mut matched = false;
2813						for (from_app, from_model, to_app, to_model) in model_renames {
2814							let from_name = format!("{}.{}", from_app, from_model);
2815							let to_name = format!("{}.{}", to_app, to_model);
2816
2817							if Self::matches_pattern(&from_name, from_pattern)
2818								&& Self::matches_pattern(&to_name, to_pattern)
2819							{
2820								evidence.push(format!(
2821									"Model renamed: {} → {} (pattern: {} → {})",
2822									from_name, to_name, from_pattern, to_pattern
2823								));
2824								matched = true;
2825								break;
2826							}
2827						}
2828
2829						if !matched {
2830							matches_required = false;
2831							break;
2832						}
2833					}
2834					RuleCondition::ModelMove { app_pattern } => {
2835						if model_moves.is_empty() {
2836							matches_required = false;
2837							break;
2838						}
2839
2840						// Check if any model move matches the app pattern
2841						let mut matched = false;
2842						for (from_app, from_model, to_app, to_model) in model_moves {
2843							if Self::matches_pattern(to_app, app_pattern) {
2844								evidence.push(format!(
2845									"Model moved: {}.{} → {}.{} (app pattern: {})",
2846									from_app, from_model, to_app, to_model, app_pattern
2847								));
2848								matched = true;
2849								break;
2850							}
2851						}
2852
2853						if !matched {
2854							matches_required = false;
2855							break;
2856						}
2857					}
2858					RuleCondition::FieldAddition { field_name_pattern } => {
2859						let matching_fields: Vec<_> = field_additions
2860							.iter()
2861							.filter(|(_, _, field)| {
2862								Self::matches_pattern(field, field_name_pattern)
2863							})
2864							.collect();
2865
2866						if matching_fields.is_empty() {
2867							matches_required = false;
2868							break;
2869						}
2870						evidence.push(format!(
2871							"Field added: {}.{}.{} (pattern: {})",
2872							matching_fields[0].0,
2873							matching_fields[0].1,
2874							matching_fields[0].2,
2875							field_name_pattern
2876						));
2877					}
2878					RuleCondition::FieldRename {
2879						from_pattern,
2880						to_pattern,
2881					} => {
2882						if field_renames.is_empty() {
2883							matches_required = false;
2884							break;
2885						}
2886
2887						// Check if any field rename matches the patterns
2888						let mut matched = false;
2889						for (app, model, from_field, to_field) in field_renames {
2890							if Self::matches_pattern(from_field, from_pattern)
2891								&& Self::matches_pattern(to_field, to_pattern)
2892							{
2893								evidence.push(format!(
2894									"Field renamed: {}.{}.{} → {} (pattern: {} → {})",
2895									app, model, from_field, to_field, from_pattern, to_pattern
2896								));
2897								matched = true;
2898								break;
2899							}
2900						}
2901
2902						if !matched {
2903							matches_required = false;
2904							break;
2905						}
2906					}
2907					RuleCondition::MultipleModelRenames { min_count } => {
2908						if model_renames.len() < *min_count {
2909							matches_required = false;
2910							break;
2911						}
2912						evidence.push(format!("Multiple model renames: {}", model_renames.len()));
2913					}
2914					RuleCondition::MultipleFieldAdditions {
2915						model_pattern,
2916						min_count,
2917					} => {
2918						let count = field_additions
2919							.iter()
2920							.filter(|(_, model, _)| Self::matches_pattern(model, model_pattern))
2921							.count();
2922
2923						if count < *min_count {
2924							matches_required = false;
2925							break;
2926						}
2927						evidence.push(format!(
2928							"Multiple field additions: {} (pattern: {}, min: {})",
2929							count, model_pattern, min_count
2930						));
2931					}
2932				}
2933			}
2934
2935			if !matches_required {
2936				continue;
2937			}
2938
2939			// Check optional conditions
2940			for condition in &rule.optional_conditions {
2941				match condition {
2942					RuleCondition::FieldAddition { field_name_pattern } => {
2943						if field_additions
2944							.iter()
2945							.any(|(_, _, field)| field.contains(field_name_pattern.as_str()))
2946						{
2947							optional_matches += 1;
2948							evidence.push(format!("Optional field added: {}", field_name_pattern));
2949						}
2950					}
2951					RuleCondition::MultipleModelRenames { min_count } => {
2952						if model_renames.len() >= *min_count {
2953							optional_matches += 1;
2954							evidence.push(format!("Multiple renames: {}", model_renames.len()));
2955						}
2956					}
2957					_ => {}
2958				}
2959			}
2960
2961			// Calculate confidence
2962			let confidence = rule.base_confidence
2963				+ (optional_matches as f64 * rule.confidence_boost_per_optional);
2964			let confidence = confidence.min(1.0);
2965
2966			intents.push(InferredIntent {
2967				intent_type: rule.intent_type.clone(),
2968				confidence,
2969				description: format!("Detected: {}", rule.name),
2970				evidence,
2971				related_operations: Vec::new(),
2972			});
2973		}
2974
2975		// Sort by confidence (highest first)
2976		intents.sort_by(|a, b| {
2977			b.confidence
2978				.partial_cmp(&a.confidence)
2979				.unwrap_or(std::cmp::Ordering::Equal)
2980		});
2981
2982		intents
2983	}
2984
2985	/// Infer intents from DetectedChanges
2986	///
2987	/// Extracts change operations from DetectedChanges and runs inference rules on them.
2988	///
2989	/// # Arguments
2990	/// * `changes` - Detected changes between two project states
2991	///
2992	/// # Returns
2993	/// Inferred intents sorted by confidence (highest first)
2994	pub fn infer_from_detected_changes(&self, changes: &DetectedChanges) -> Vec<InferredIntent> {
2995		// Extract model renames: (from_app, from_model, to_app, to_model)
2996		let model_renames: Vec<(String, String, String, String)> = changes
2997			.renamed_models
2998			.iter()
2999			.map(|(app, old_name, new_name)| {
3000				(app.clone(), old_name.clone(), app.clone(), new_name.clone())
3001			})
3002			.collect();
3003
3004		// Extract model moves: (from_app, from_model, to_app, to_model)
3005		let model_moves: Vec<(String, String, String, String)> = changes
3006			.moved_models
3007			.iter()
3008			.map(|(from_app, to_app, model, _, _, _)| {
3009				(
3010					from_app.clone(),
3011					model.clone(),
3012					to_app.clone(),
3013					model.clone(),
3014				)
3015			})
3016			.collect();
3017
3018		// Extract field additions: (app, model, field)
3019		let field_additions: Vec<(String, String, String)> = changes
3020			.added_fields
3021			.iter()
3022			.map(|(app, model, field)| (app.clone(), model.clone(), field.clone()))
3023			.collect();
3024
3025		// Extract field renames: (app, model, from_field, to_field)
3026		let field_renames: Vec<(String, String, String, String)> = changes
3027			.renamed_fields
3028			.iter()
3029			.map(|(app, model, old_name, new_name)| {
3030				(
3031					app.clone(),
3032					model.clone(),
3033					old_name.clone(),
3034					new_name.clone(),
3035				)
3036			})
3037			.collect();
3038
3039		// Run inference on extracted changes
3040		let mut intents = self.infer_intents(
3041			&model_renames,
3042			&model_moves,
3043			&field_additions,
3044			&field_renames,
3045		);
3046
3047		// Post-process: populate related_operations for each intent based on evidence
3048		for intent in &mut intents {
3049			// Parse evidence to determine which operations are related
3050			// Evidence strings contain information about which changes triggered the intent
3051			for evidence_str in &intent.evidence {
3052				// Model rename evidence: "Model renamed: app.old → app.new ..."
3053				if evidence_str.starts_with("Model renamed:") {
3054					for (app, old_name, new_name) in &changes.renamed_models {
3055						intent.related_operations.push(OperationRef::RenamedModel {
3056							app_label: app.clone(),
3057							old_name: old_name.clone(),
3058							new_name: new_name.clone(),
3059						});
3060					}
3061				}
3062				// Model move evidence: "Model moved: from_app.model → to_app.model ..."
3063				else if evidence_str.starts_with("Model moved:") {
3064					for (from_app, to_app, model, _, _, _) in &changes.moved_models {
3065						intent.related_operations.push(OperationRef::MovedModel {
3066							from_app: from_app.clone(),
3067							to_app: to_app.clone(),
3068							model_name: model.clone(),
3069						});
3070					}
3071				}
3072				// Field added evidence: "Field added: app.model.field ..."
3073				else if evidence_str.starts_with("Field added:") {
3074					for (app, model, field) in &changes.added_fields {
3075						intent.related_operations.push(OperationRef::AddedField {
3076							app_label: app.clone(),
3077							model_name: model.clone(),
3078							field_name: field.clone(),
3079						});
3080					}
3081				}
3082				// Field renamed evidence: "Field renamed: app.model.old → new ..."
3083				else if evidence_str.starts_with("Field renamed:") {
3084					for (app, model, old_name, new_name) in &changes.renamed_fields {
3085						intent.related_operations.push(OperationRef::RenamedField {
3086							app_label: app.clone(),
3087							model_name: model.clone(),
3088							old_name: old_name.clone(),
3089							new_name: new_name.clone(),
3090						});
3091					}
3092				}
3093				// Multiple model renames evidence
3094				else if evidence_str.starts_with("Multiple model renames:") {
3095					for (app, old_name, new_name) in &changes.renamed_models {
3096						intent.related_operations.push(OperationRef::RenamedModel {
3097							app_label: app.clone(),
3098							old_name: old_name.clone(),
3099							new_name: new_name.clone(),
3100						});
3101					}
3102				}
3103				// Multiple field additions or optional field added evidence
3104				else if evidence_str.starts_with("Multiple field additions:")
3105					|| evidence_str.starts_with("Optional field added:")
3106				{
3107					for (app, model, field) in &changes.added_fields {
3108						intent.related_operations.push(OperationRef::AddedField {
3109							app_label: app.clone(),
3110							model_name: model.clone(),
3111							field_name: field.clone(),
3112						});
3113					}
3114				}
3115			}
3116
3117			// Deduplicate related_operations
3118			intent
3119				.related_operations
3120				.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b)));
3121			intent.related_operations.dedup();
3122		}
3123
3124		intents
3125	}
3126
3127	/// Record a model rename in the change tracker
3128	///
3129	/// This enables contextual analysis for future migrations by tracking patterns.
3130	///
3131	/// # Arguments
3132	/// * `app_label` - App containing the model
3133	/// * `old_name` - Original model name
3134	/// * `new_name` - New model name
3135	pub fn record_model_rename(&mut self, app_label: &str, old_name: &str, new_name: &str) {
3136		self.change_tracker
3137			.record_model_rename(app_label, old_name, new_name);
3138	}
3139
3140	/// Record a model move between apps
3141	///
3142	/// # Arguments
3143	/// * `from_app` - Source app label
3144	/// * `to_app` - Target app label
3145	/// * `model_name` - Name of the model being moved
3146	pub fn record_model_move(&mut self, from_app: &str, to_app: &str, model_name: &str) {
3147		self.change_tracker
3148			.record_model_move(from_app, to_app, model_name);
3149	}
3150
3151	/// Record a field addition
3152	///
3153	/// # Arguments
3154	/// * `app_label` - App containing the model
3155	/// * `model_name` - Name of the model
3156	/// * `field_name` - Name of the field being added
3157	pub fn record_field_addition(&mut self, app_label: &str, model_name: &str, field_name: &str) {
3158		self.change_tracker
3159			.record_field_addition(app_label, model_name, field_name);
3160	}
3161
3162	/// Record a field rename
3163	///
3164	/// # Arguments
3165	/// * `app_label` - App containing the model
3166	/// * `model_name` - Name of the model
3167	/// * `old_name` - Original field name
3168	/// * `new_name` - New field name
3169	pub fn record_field_rename(
3170		&mut self,
3171		app_label: &str,
3172		model_name: &str,
3173		old_name: &str,
3174		new_name: &str,
3175	) {
3176		self.change_tracker
3177			.record_field_rename(app_label, model_name, old_name, new_name);
3178	}
3179
3180	/// Get frequent patterns from change history
3181	///
3182	/// Returns patterns that occur at least `min_frequency` times.
3183	/// This can be used to improve confidence scores for similar patterns.
3184	///
3185	/// # Arguments
3186	/// * `min_frequency` - Minimum number of occurrences to be considered frequent
3187	pub fn get_frequent_patterns(&self, min_frequency: usize) -> Vec<PatternFrequency> {
3188		self.change_tracker.get_frequent_patterns(min_frequency)
3189	}
3190
3191	/// Get recent changes within the specified duration
3192	///
3193	/// # Arguments
3194	/// * `duration` - Time window for recent changes (e.g., last hour)
3195	pub fn get_recent_changes(&self, duration: std::time::Duration) -> Vec<&ChangeHistoryEntry> {
3196		self.change_tracker.get_recent_changes(duration)
3197	}
3198
3199	/// Analyze co-occurring patterns in change history
3200	///
3201	/// Returns pairs of patterns that frequently appear together
3202	/// within a time window.
3203	///
3204	/// # Arguments
3205	/// * `window` - Time window for co-occurrence analysis (default: 1 hour)
3206	pub fn analyze_cooccurrence(
3207		&self,
3208		window: std::time::Duration,
3209	) -> HashMap<(String, String), usize> {
3210		self.change_tracker.analyze_cooccurrence(window)
3211	}
3212}
3213
3214// ============================================================================
3215// Interactive UI for User Confirmation
3216// ============================================================================
3217
3218/// Interactive prompt system for user confirmation of ambiguous changes
3219///
3220/// This module provides CLI-based prompts for:
3221/// - Ambiguous model/field renames
3222/// - Cross-app model moves
3223/// - Multiple possible intents with different confidence scores
3224///
3225/// Uses the `dialoguer` crate for rich terminal interactions.
3226pub struct MigrationPrompt {
3227	/// Minimum confidence threshold for auto-acceptance (0.0 - 1.0)
3228	/// Changes above this threshold are accepted without prompting
3229	auto_accept_threshold: f64,
3230
3231	/// Theme for terminal styling
3232	theme: dialoguer::theme::ColorfulTheme,
3233}
3234
3235impl std::fmt::Debug for MigrationPrompt {
3236	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3237		f.debug_struct("MigrationPrompt")
3238			.field("auto_accept_threshold", &self.auto_accept_threshold)
3239			.field("theme", &"ColorfulTheme")
3240			.finish()
3241	}
3242}
3243
3244impl MigrationPrompt {
3245	/// Create a new prompt system with default settings
3246	pub fn new() -> Self {
3247		Self {
3248			auto_accept_threshold: 0.85,
3249			theme: dialoguer::theme::ColorfulTheme::default(),
3250		}
3251	}
3252
3253	/// Create with custom auto-accept threshold
3254	pub fn with_threshold(threshold: f64) -> Self {
3255		Self {
3256			auto_accept_threshold: threshold,
3257			theme: dialoguer::theme::ColorfulTheme::default(),
3258		}
3259	}
3260
3261	/// Get the auto-accept threshold
3262	pub fn auto_accept_threshold(&self) -> f64 {
3263		self.auto_accept_threshold
3264	}
3265
3266	/// Confirm a single intent with the user
3267	///
3268	/// Returns true if the user confirms, false if they reject
3269	pub fn confirm_intent(
3270		&self,
3271		intent: &InferredIntent,
3272	) -> Result<bool, Box<dyn std::error::Error>> {
3273		// Auto-accept high-confidence changes
3274		if intent.confidence >= self.auto_accept_threshold {
3275			println!(
3276				"✓ Auto-accepting (confidence: {:.1}%): {}",
3277				intent.confidence * 100.0,
3278				intent.intent_type
3279			);
3280			return Ok(true);
3281		}
3282
3283		// Build prompt message
3284		let message = format!(
3285			"Detected: {} (confidence: {:.1}%)\nDetails: {}\n\nAccept this change?",
3286			intent.intent_type,
3287			intent.confidence * 100.0,
3288			intent.description
3289		);
3290
3291		// Show evidence
3292		if !intent.evidence.is_empty() {
3293			println!("\nEvidence:");
3294			for evidence in &intent.evidence {
3295				println!("  • {}", evidence);
3296			}
3297		}
3298
3299		// Prompt user
3300		dialoguer::Confirm::with_theme(&self.theme)
3301			.with_prompt(message)
3302			.default(true)
3303			.interact()
3304			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
3305	}
3306
3307	/// Select one intent from multiple alternatives
3308	///
3309	/// Returns the index of the selected intent, or None if user cancels
3310	pub fn select_intent(
3311		&self,
3312		alternatives: &[InferredIntent],
3313		prompt: &str,
3314	) -> Result<Option<usize>, Box<dyn std::error::Error>> {
3315		if alternatives.is_empty() {
3316			return Ok(None);
3317		}
3318
3319		// Single alternative - just confirm
3320		if alternatives.len() == 1 {
3321			let confirmed = self.confirm_intent(&alternatives[0])?;
3322			return Ok(if confirmed { Some(0) } else { None });
3323		}
3324
3325		// Build selection items
3326		let items: Vec<String> = alternatives
3327			.iter()
3328			.map(|intent| {
3329				format!(
3330					"{} (confidence: {:.1}%) - {}",
3331					intent.intent_type,
3332					intent.confidence * 100.0,
3333					intent.description
3334				)
3335			})
3336			.collect();
3337
3338		// Show prompt
3339		println!("\n{}", prompt);
3340		println!("Multiple possibilities detected:\n");
3341
3342		// Add "None of the above" option
3343		let mut items_with_none = items.clone();
3344		items_with_none.push("None of the above / Skip".to_string());
3345
3346		// Prompt user
3347		let selection = dialoguer::Select::with_theme(&self.theme)
3348			.items(&items_with_none)
3349			.default(0)
3350			.interact()
3351			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
3352
3353		// Return None if user selected "None of the above"
3354		if selection >= items.len() {
3355			Ok(None)
3356		} else {
3357			Ok(Some(selection))
3358		}
3359	}
3360
3361	/// Multi-select intents from a list
3362	///
3363	/// Returns indices of selected intents
3364	pub fn multi_select_intents(
3365		&self,
3366		alternatives: &[InferredIntent],
3367		prompt: &str,
3368	) -> Result<Vec<usize>, Box<dyn std::error::Error>> {
3369		if alternatives.is_empty() {
3370			return Ok(Vec::new());
3371		}
3372
3373		// Build selection items
3374		let items: Vec<String> = alternatives
3375			.iter()
3376			.map(|intent| {
3377				format!(
3378					"{} (confidence: {:.1}%) - {}",
3379					intent.intent_type,
3380					intent.confidence * 100.0,
3381					intent.description
3382				)
3383			})
3384			.collect();
3385
3386		// Show prompt
3387		println!("\n{}", prompt);
3388		println!("Select all that apply:\n");
3389
3390		// Prompt user with multi-select
3391		let selections = dialoguer::MultiSelect::with_theme(&self.theme)
3392			.items(&items)
3393			.interact()
3394			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
3395
3396		Ok(selections)
3397	}
3398
3399	/// Confirm a model rename with details
3400	pub fn confirm_model_rename(
3401		&self,
3402		from_app: &str,
3403		from_model: &str,
3404		to_app: &str,
3405		to_model: &str,
3406		confidence: f64,
3407	) -> Result<bool, Box<dyn std::error::Error>> {
3408		// Auto-accept high-confidence changes
3409		if confidence >= self.auto_accept_threshold {
3410			println!(
3411				"✓ Auto-accepting model rename (confidence: {:.1}%): {}.{} → {}.{}",
3412				confidence * 100.0,
3413				from_app,
3414				from_model,
3415				to_app,
3416				to_model
3417			);
3418			return Ok(true);
3419		}
3420
3421		let message = format!(
3422			"Rename model from {}.{} to {}.{}?\n(confidence: {:.1}%)",
3423			from_app,
3424			from_model,
3425			to_app,
3426			to_model,
3427			confidence * 100.0
3428		);
3429
3430		dialoguer::Confirm::with_theme(&self.theme)
3431			.with_prompt(message)
3432			.default(true)
3433			.interact()
3434			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
3435	}
3436
3437	/// Confirm a field rename with details
3438	pub fn confirm_field_rename(
3439		&self,
3440		model: &str,
3441		from_field: &str,
3442		to_field: &str,
3443		confidence: f64,
3444	) -> Result<bool, Box<dyn std::error::Error>> {
3445		// Auto-accept high-confidence changes
3446		if confidence >= self.auto_accept_threshold {
3447			println!(
3448				"✓ Auto-accepting field rename (confidence: {:.1}%): {}.{} → {}.{}",
3449				confidence * 100.0,
3450				model,
3451				from_field,
3452				model,
3453				to_field
3454			);
3455			return Ok(true);
3456		}
3457
3458		let message = format!(
3459			"Rename field in model {}:\n  {} → {}?\n(confidence: {:.1}%)",
3460			model,
3461			from_field,
3462			to_field,
3463			confidence * 100.0
3464		);
3465
3466		dialoguer::Confirm::with_theme(&self.theme)
3467			.with_prompt(message)
3468			.default(true)
3469			.interact()
3470			.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
3471	}
3472
3473	/// Show progress indicator for long operations
3474	pub fn with_progress<F, T>(
3475		&self,
3476		message: &str,
3477		total: u64,
3478		operation: F,
3479	) -> Result<T, Box<dyn std::error::Error>>
3480	where
3481		F: FnOnce(&indicatif::ProgressBar) -> Result<T, Box<dyn std::error::Error>>,
3482	{
3483		let pb = indicatif::ProgressBar::new(total);
3484		pb.set_style(
3485			indicatif::ProgressStyle::default_bar()
3486				.template("{msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
3487				.expect("Failed to create progress bar template")
3488				.progress_chars("#>-"),
3489		);
3490		pb.set_message(message.to_string());
3491
3492		let result = operation(&pb)?;
3493
3494		pb.finish_with_message("Done");
3495		Ok(result)
3496	}
3497}
3498
3499impl Default for MigrationPrompt {
3500	fn default() -> Self {
3501		Self::new()
3502	}
3503}
3504
3505/// Extension trait for MigrationAutodetector with interactive prompts
3506pub trait InteractiveAutodetector {
3507	/// Detect changes with user prompts for ambiguous cases
3508	fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>>;
3509
3510	/// Apply inferred intents with user confirmation
3511	fn apply_intents_interactive(
3512		&self,
3513		intents: Vec<InferredIntent>,
3514		changes: &mut DetectedChanges,
3515	) -> Result<(), Box<dyn std::error::Error>>;
3516}
3517
3518impl InteractiveAutodetector for MigrationAutodetector {
3519	fn detect_changes_interactive(&self) -> Result<DetectedChanges, Box<dyn std::error::Error>> {
3520		let prompt = MigrationPrompt::new();
3521		let mut changes = self.detect_changes();
3522
3523		// Build inference engine
3524		let mut engine = InferenceEngine::new();
3525		engine.add_default_rules();
3526
3527		// Infer intents from detected changes
3528		let intents = engine.infer_from_detected_changes(&changes);
3529
3530		// Filter high-confidence intents
3531		let ambiguous_intents: Vec<_> = intents
3532			.into_iter()
3533			.filter(|intent| intent.confidence < prompt.auto_accept_threshold)
3534			.collect();
3535
3536		// Prompt for ambiguous changes
3537		if !ambiguous_intents.is_empty() {
3538			println!(
3539				"\n⚠️  Found {} ambiguous change(s) requiring confirmation:",
3540				ambiguous_intents.len()
3541			);
3542
3543			for intent in &ambiguous_intents {
3544				let confirmed = prompt.confirm_intent(intent)?;
3545
3546				if !confirmed {
3547					println!("✗ Skipped: {}", intent.description);
3548					// Remove the related operations from DetectedChanges
3549					// This prevents rejected intents from generating migration operations
3550					if !intent.related_operations.is_empty() {
3551						changes.remove_operations(&intent.related_operations);
3552						println!(
3553							"  → Removed {} related operation(s) from migration",
3554							intent.related_operations.len()
3555						);
3556					}
3557				}
3558			}
3559		}
3560
3561		// Detect and order dependencies
3562		self.detect_model_dependencies(&mut changes);
3563
3564		// Check for circular dependencies
3565		if let Err(cycle) = changes.check_circular_dependencies() {
3566			println!("\n⚠️  Warning: Circular dependency detected: {:?}", cycle);
3567
3568			let should_continue = dialoguer::Confirm::new()
3569				.with_prompt("Continue anyway? (may require manual intervention)")
3570				.default(false)
3571				.interact()?;
3572
3573			if !should_continue {
3574				return Err("Aborted due to circular dependency".into());
3575			}
3576		}
3577
3578		Ok(changes)
3579	}
3580
3581	fn apply_intents_interactive(
3582		&self,
3583		intents: Vec<InferredIntent>,
3584		_changes: &mut DetectedChanges,
3585	) -> Result<(), Box<dyn std::error::Error>> {
3586		let prompt = MigrationPrompt::new();
3587
3588		// Group intents by confidence
3589		let mut high_confidence = Vec::new();
3590		let mut medium_confidence = Vec::new();
3591		let mut low_confidence = Vec::new();
3592
3593		for intent in intents {
3594			if intent.confidence >= 0.85 {
3595				high_confidence.push(intent);
3596			} else if intent.confidence >= 0.65 {
3597				medium_confidence.push(intent);
3598			} else {
3599				low_confidence.push(intent);
3600			}
3601		}
3602
3603		// Auto-apply high-confidence intents
3604		println!(
3605			"\n✓ Auto-applying {} high-confidence change(s):",
3606			high_confidence.len()
3607		);
3608		for intent in &high_confidence {
3609			println!(
3610				"  • {} (confidence: {:.1}%)",
3611				intent.description,
3612				intent.confidence * 100.0
3613			);
3614		}
3615
3616		// Prompt for medium-confidence intents
3617		if !medium_confidence.is_empty() {
3618			println!(
3619				"\n⚠️  Review {} medium-confidence change(s):",
3620				medium_confidence.len()
3621			);
3622
3623			for intent in &medium_confidence {
3624				let confirmed = prompt.confirm_intent(intent)?;
3625				if confirmed {
3626					println!("  ✓ Accepted: {}", intent.description);
3627				} else {
3628					println!("  ✗ Rejected: {}", intent.description);
3629				}
3630			}
3631		}
3632
3633		// Prompt for low-confidence intents with multi-select
3634		if !low_confidence.is_empty() {
3635			let selections = prompt.multi_select_intents(
3636				&low_confidence,
3637				"⚠️  Select low-confidence changes to apply:",
3638			)?;
3639
3640			for idx in selections {
3641				println!("  ✓ Accepted: {}", low_confidence[idx].description);
3642			}
3643		}
3644
3645		Ok(())
3646	}
3647}
3648
3649impl MigrationAutodetector {
3650	/// Create a new migration autodetector with default similarity config
3651	///
3652	/// # Examples
3653	///
3654	/// ```rust,ignore
3655	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState};
3656	///
3657	/// let from_state = ProjectState::new();
3658	/// let to_state = ProjectState::new();
3659	///
3660	/// let detector = MigrationAutodetector::new(from_state, to_state);
3661	/// ```
3662	pub fn new(from_state: ProjectState, to_state: ProjectState) -> Self {
3663		Self {
3664			from_state,
3665			to_state,
3666			similarity_config: SimilarityConfig::default(),
3667		}
3668	}
3669
3670	/// Create a new migration autodetector with custom similarity config
3671	///
3672	/// # Examples
3673	///
3674	/// ```rust,ignore
3675	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, SimilarityConfig};
3676	///
3677	/// let from_state = ProjectState::new();
3678	/// let to_state = ProjectState::new();
3679	/// let config = SimilarityConfig::new(0.75, 0.85).unwrap();
3680	///
3681	/// let detector = MigrationAutodetector::with_config(from_state, to_state, config);
3682	/// ```
3683	pub fn with_config(
3684		from_state: ProjectState,
3685		to_state: ProjectState,
3686		similarity_config: SimilarityConfig,
3687	) -> Self {
3688		Self {
3689			from_state,
3690			to_state,
3691			similarity_config,
3692		}
3693	}
3694
3695	/// Detect all changes between from_state and to_state
3696	///
3697	/// Django equivalent: `_detect_changes()` in django/db/migrations/autodetector.py
3698	///
3699	/// # Examples
3700	///
3701	/// ```rust,ignore
3702	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState};
3703	///
3704	/// let from_state = ProjectState::new();
3705	/// let mut to_state = ProjectState::new();
3706	///
3707	/// // Add a new model
3708	/// let model = ModelState::new("myapp", "User");
3709	/// to_state.add_model(model);
3710	///
3711	/// let detector = MigrationAutodetector::new(from_state, to_state);
3712	/// let changes = detector.detect_changes();
3713	///
3714	/// assert_eq!(changes.created_models.len(), 1);
3715	/// ```
3716	pub fn detect_changes(&self) -> DetectedChanges {
3717		let mut changes = DetectedChanges::default();
3718
3719		// Detect model-level changes
3720		self.detect_created_models(&mut changes);
3721		self.detect_deleted_models(&mut changes);
3722		self.detect_renamed_models(&mut changes);
3723
3724		// Detect field-level changes (only for models that exist in both states)
3725		self.detect_added_fields(&mut changes);
3726		self.detect_removed_fields(&mut changes);
3727		self.detect_altered_fields(&mut changes);
3728		self.detect_renamed_fields(&mut changes);
3729
3730		// Detect index and constraint changes
3731		self.detect_added_indexes(&mut changes);
3732		self.detect_removed_indexes(&mut changes);
3733		self.detect_added_constraints(&mut changes);
3734		self.detect_removed_constraints(&mut changes);
3735		self.detect_composite_pk_changes(&mut changes);
3736		self.detect_auto_increment_resets(&mut changes);
3737
3738		// Detect ManyToMany intermediate tables
3739		self.detect_created_many_to_many(&mut changes);
3740
3741		// Detect model dependencies for operation ordering
3742		self.detect_model_dependencies(&mut changes);
3743
3744		// Sort all changes to ensure deterministic ordering
3745		// This guarantees that the same model set always produces the same migration order
3746		changes.created_models.sort();
3747		changes.deleted_models.sort();
3748		changes.added_fields.sort();
3749		changes.removed_fields.sort();
3750		changes.altered_fields.sort();
3751		changes.renamed_models.sort();
3752		changes.renamed_fields.sort();
3753
3754		// Sort by (app_label, model_name) for index and constraint changes
3755		changes
3756			.added_indexes
3757			.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
3758		changes.removed_indexes.sort();
3759		changes
3760			.added_constraints
3761			.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
3762		changes.removed_constraints.sort();
3763		changes
3764			.added_composite_primary_keys
3765			.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
3766		changes.removed_composite_primary_keys.sort();
3767		changes.auto_increment_resets.sort();
3768		changes
3769			.created_many_to_many
3770			.sort_by(|a, b| (&a.0, &a.1, &a.2).cmp(&(&b.0, &b.1, &b.2)));
3771
3772		changes
3773	}
3774
3775	/// Detect newly created models
3776	///
3777	/// Django reference: `generate_created_models()` in django/db/migrations/autodetector.py:800
3778	fn detect_created_models(&self, changes: &mut DetectedChanges) {
3779		for ((app_label, model_name), to_model) in &self.to_state.models {
3780			// Check if the model exists in from_state by table name
3781			if self
3782				.from_state
3783				.get_model_by_table_name(app_label, &to_model.table_name)
3784				.is_none()
3785			{
3786				changes
3787					.created_models
3788					.push((app_label.clone(), model_name.clone()));
3789			}
3790		}
3791	}
3792
3793	/// Detect deleted models
3794	///
3795	/// Django reference: `generate_deleted_models()` in django/db/migrations/autodetector.py:900
3796	fn detect_deleted_models(&self, changes: &mut DetectedChanges) {
3797		for ((app_label, model_name), from_model) in &self.from_state.models {
3798			// Check if the model exists in to_state by table name
3799			if self
3800				.to_state
3801				.get_model_by_table_name(app_label, &from_model.table_name)
3802				.is_none()
3803			{
3804				changes
3805					.deleted_models
3806					.push((app_label.clone(), model_name.clone()));
3807			}
3808		}
3809	}
3810
3811	/// Detect added fields
3812	///
3813	/// Django reference: `generate_added_fields()` in django/db/migrations/autodetector.py:1000
3814	fn detect_added_fields(&self, changes: &mut DetectedChanges) {
3815		for ((app_label, model_name), to_model) in &self.to_state.models {
3816			// Only check models that exist in both states (by table name)
3817			if let Some(from_model) = self
3818				.from_state
3819				.get_model_by_table_name(app_label, &to_model.table_name)
3820			{
3821				for field_name in to_model.fields.keys() {
3822					if !from_model.fields.contains_key(field_name) {
3823						changes.added_fields.push((
3824							app_label.clone(),
3825							model_name.clone(),
3826							field_name.clone(),
3827						));
3828					}
3829				}
3830			}
3831		}
3832	}
3833
3834	/// Detect removed fields
3835	///
3836	/// Django reference: `generate_removed_fields()` in django/db/migrations/autodetector.py:1100
3837	fn detect_removed_fields(&self, changes: &mut DetectedChanges) {
3838		for ((app_label, model_name), from_model) in &self.from_state.models {
3839			// Only check models that exist in both states (by table name)
3840			if let Some(to_model) = self
3841				.to_state
3842				.get_model_by_table_name(app_label, &from_model.table_name)
3843			{
3844				for field_name in from_model.fields.keys() {
3845					if !to_model.fields.contains_key(field_name) {
3846						changes.removed_fields.push((
3847							app_label.clone(),
3848							model_name.clone(),
3849							field_name.clone(),
3850						));
3851					}
3852				}
3853			}
3854		}
3855	}
3856
3857	/// Detect altered fields
3858	///
3859	/// Django reference: `generate_altered_fields()` in django/db/migrations/autodetector.py:1200
3860	fn detect_altered_fields(&self, changes: &mut DetectedChanges) {
3861		for ((app_label, model_name), to_model) in &self.to_state.models {
3862			// Only check models that exist in both states (by table name)
3863			if let Some(from_model) = self
3864				.from_state
3865				.get_model_by_table_name(app_label, &to_model.table_name)
3866			{
3867				for (field_name, to_field) in &to_model.fields {
3868					if let Some(from_field) = from_model.fields.get(field_name) {
3869						// Check if field definition has changed
3870						if self.has_field_changed(field_name, from_field, to_field) {
3871							changes.altered_fields.push((
3872								app_label.clone(),
3873								model_name.clone(),
3874								field_name.clone(),
3875							));
3876						}
3877					}
3878				}
3879			}
3880		}
3881	}
3882
3883	/// Check if a field has changed.
3884	///
3885	/// Field type and nullability are compared directly against `FieldState`,
3886	/// then the rest of the schema-affecting attributes are funneled through
3887	/// `ColumnDefinition::from_field_state` so that the migration-replayed
3888	/// `from_state` and the code-registry `to_state` collapse to the same
3889	/// canonical `ColumnDefinition` regardless of how their `params`
3890	/// HashMaps were populated.
3891	///
3892	/// `from_state` (built via `ProjectState::apply_migration_operations` ->
3893	/// `column_def_to_field_state`) only inserts `primary_key`,
3894	/// `auto_increment`, `unique`, and `default` keys when the corresponding
3895	/// `ColumnDefinition` field is true / `Some`. `to_state` (built via the
3896	/// `#[model]` macro and `ModelMetadata::to_model_state`) explicitly
3897	/// inserts boolean strings such as `not_null = "true"`, `null = "false"`,
3898	/// and `unique = "false"` for every field. A raw HashMap-key comparison
3899	/// surfaces this asymmetry as a fictitious change even when the
3900	/// underlying schema is identical, producing spurious `AlterColumn`
3901	/// operations under offline file-based state reconstruction.
3902	///
3903	/// See reinhardt-web#4049 for the regression that motivated this
3904	/// canonicalization.
3905	fn has_field_changed(
3906		&self,
3907		field_name: &str,
3908		from_field: &FieldState,
3909		to_field: &FieldState,
3910	) -> bool {
3911		// Field type and nullability are compared directly because the
3912		// `nullable` bit on `FieldState` carries the authoritative NOT NULL
3913		// status (the `not_null` / `null` params are advisory only).
3914		if from_field.field_type != to_field.field_type {
3915			return true;
3916		}
3917		if from_field.nullable != to_field.nullable {
3918			return true;
3919		}
3920
3921		// Schema-affecting bits are compared via the canonical
3922		// `ColumnDefinition` form to absorb asymmetric param populations.
3923		let from_def = super::ColumnDefinition::from_field_state(field_name, from_field);
3924		let to_def = super::ColumnDefinition::from_field_state(field_name, to_field);
3925		from_def.primary_key != to_def.primary_key
3926			|| from_def.auto_increment != to_def.auto_increment
3927			|| from_def.unique != to_def.unique
3928			|| from_def.default != to_def.default
3929	}
3930
3931	/// Detect renamed models
3932	///
3933	/// This method attempts to detect model renames by comparing deleted and created models.
3934	/// It uses field similarity to determine if a model was renamed rather than deleted/created.
3935	///
3936	/// # Django Reference
3937	/// From: django/db/migrations/autodetector.py:620-750
3938	/// ```python
3939	/// def generate_renamed_models(self):
3940	///     # Find models that were deleted and created with similar fields
3941	///     for (app_label, old_model_name) in self.old_model_keys - self.new_model_keys:
3942	///         for (app_label, new_model_name) in self.new_model_keys - self.old_model_keys:
3943	///             if self._is_renamed_model(old_model_name, new_model_name):
3944	///                 self.add_operation(
3945	///                     app_label,
3946	///                     operations.RenameModel(
3947	///                         old_name=old_model_name,
3948	///                         new_name=new_model_name,
3949	///                     ),
3950	///                 )
3951	/// ```rust,ignore
3952	///
3953	/// # Examples
3954	///
3955	/// ```rust,ignore
3956	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
3957	///
3958	/// let mut from_state = ProjectState::new();
3959	/// let mut old_model = ModelState::new("myapp", "OldUser");
3960	/// old_model.add_field(FieldState::new("id", FieldType::Integer, false));
3961	/// old_model.add_field(FieldState::new("name", FieldType::VarChar(255), false));
3962	/// from_state.add_model(old_model);
3963	///
3964	/// let mut to_state = ProjectState::new();
3965	/// let mut new_model = ModelState::new("myapp", "NewUser");
3966	/// new_model.add_field(FieldState::new("id", FieldType::Integer, false));
3967	/// new_model.add_field(FieldState::new("name", FieldType::VarChar(255), false));
3968	/// to_state.add_model(new_model);
3969	///
3970	/// let detector = MigrationAutodetector::new(from_state, to_state);
3971	/// let changes = detector.detect_changes();
3972	///
3973	/// // With high field similarity, should detect as rename
3974	/// assert!(changes.renamed_models.len() <= 1);
3975	/// ```
3976	fn detect_renamed_models(&self, changes: &mut DetectedChanges) {
3977		// Get deleted and created models
3978		let deleted: Vec<_> = self
3979			.from_state
3980			.models
3981			.keys()
3982			.filter(|k| !self.to_state.models.contains_key(k))
3983			.collect();
3984
3985		let created: Vec<_> = self
3986			.to_state
3987			.models
3988			.keys()
3989			.filter(|k| !self.from_state.models.contains_key(k))
3990			.collect();
3991
3992		// Use bipartite matching to find optimal model pairs
3993		// This supports both same-app renames and cross-app moves
3994		let matches = self.find_optimal_model_matches(&deleted, &created);
3995
3996		for (deleted_key, created_key, _similarity) in matches {
3997			// Check if this is a cross-app move or same-app rename
3998			if deleted_key.0 == created_key.0 {
3999				// Same app: check if table names actually differ
4000				// Struct-only renames (same table name) are not schema changes
4001				let old_table = self
4002					.from_state
4003					.get_model(&deleted_key.0, &deleted_key.1)
4004					.map(|m| m.table_name.as_str());
4005				let new_table = self
4006					.to_state
4007					.get_model(&created_key.0, &created_key.1)
4008					.map(|m| m.table_name.as_str());
4009
4010				if old_table != new_table {
4011					changes
4012						.renamed_models
4013						.push((deleted_key.0, deleted_key.1, created_key.1));
4014				}
4015			} else {
4016				// Different apps: this is a move operation
4017				// Determine if table needs to be renamed
4018				let old_table = format!("{}_{}", deleted_key.0, deleted_key.1.to_lowercase());
4019				let new_table = format!("{}_{}", created_key.0, created_key.1.to_lowercase());
4020				let rename_table = old_table != new_table || deleted_key.1 != created_key.1;
4021
4022				changes.moved_models.push((
4023					deleted_key.0, // from_app
4024					created_key.0, // to_app
4025					created_key.1, // model_name (use new name)
4026					rename_table,
4027					if rename_table { Some(old_table) } else { None },
4028					if rename_table { Some(new_table) } else { None },
4029				));
4030			}
4031		}
4032	}
4033
4034	/// Detect renamed fields
4035	///
4036	/// This method attempts to detect field renames by comparing removed and added fields.
4037	///
4038	/// # Django Reference
4039	/// From: django/db/migrations/autodetector.py:1300-1400
4040	/// ```python
4041	/// def generate_renamed_fields(self):
4042	///     for app_label, model_name in sorted(self.kept_model_keys):
4043	///         old_model_state = self.from_state.models[app_label, model_name]
4044	///         new_model_state = self.to_state.models[app_label, model_name]
4045	///
4046	///         # Find fields that were removed and added with same type
4047	///         for old_field_name, old_field in old_model_state.fields:
4048	///             for new_field_name, new_field in new_model_state.fields:
4049	///                 if self._is_renamed_field(old_field, new_field):
4050	///                     self.add_operation(...)
4051	/// ```rust,ignore
4052	///
4053	/// # Examples
4054	///
4055	/// ```rust,ignore
4056	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4057	///
4058	/// let mut from_state = ProjectState::new();
4059	/// let mut old_model = ModelState::new("myapp", "User");
4060	/// old_model.add_field(FieldState::new("old_email", FieldType::VarChar(255), false));
4061	/// from_state.add_model(old_model);
4062	///
4063	/// let mut to_state = ProjectState::new();
4064	/// let mut new_model = ModelState::new("myapp", "User");
4065	/// new_model.add_field(FieldState::new("new_email", FieldType::VarChar(255), false));
4066	/// to_state.add_model(new_model);
4067	///
4068	/// let detector = MigrationAutodetector::new(from_state, to_state);
4069	/// let changes = detector.detect_changes();
4070	///
4071	/// // With matching type, might detect as rename
4072	/// assert!(changes.renamed_fields.len() <= 1);
4073	/// ```
4074	fn detect_renamed_fields(&self, changes: &mut DetectedChanges) {
4075		// Only check models that exist in both states
4076		for ((app_label, model_name), from_model) in &self.from_state.models {
4077			if let Some(to_model) = self.to_state.get_model(app_label, model_name) {
4078				// Get removed and added fields for this model
4079				let removed_fields: Vec<_> = from_model
4080					.fields
4081					.iter()
4082					.filter(|(name, _)| !to_model.fields.contains_key(*name))
4083					.collect();
4084
4085				let added_fields: Vec<_> = to_model
4086					.fields
4087					.iter()
4088					.filter(|(name, _)| !from_model.fields.contains_key(*name))
4089					.collect();
4090
4091				// Try to match removed fields with added fields
4092				for (removed_name, removed_field) in &removed_fields {
4093					for (added_name, added_field) in &added_fields {
4094						// If field types match, consider it a rename
4095						if removed_field.field_type == added_field.field_type
4096							&& removed_field.nullable == added_field.nullable
4097						{
4098							changes.renamed_fields.push((
4099								app_label.clone(),
4100								model_name.clone(),
4101								removed_name.to_string(),
4102								added_name.to_string(),
4103							));
4104							break;
4105						}
4106					}
4107				}
4108			}
4109		}
4110	}
4111
4112	/// Calculate similarity between two models using advanced field matching
4113	///
4114	/// # Algorithm: Weighted Bipartite Matching for Fields
4115	/// - Uses Jaro-Winkler for field name similarity
4116	/// - Time Complexity: O(n*m) where n,m are number of fields
4117	/// - Considers both exact matches and fuzzy matches
4118	///
4119	/// # Scoring:
4120	/// - Exact field name + type match: 1.0
4121	/// - Fuzzy field name + type match: Jaro-Winkler score (0.0-1.0)
4122	/// - No type match: 0.0
4123	///
4124	/// Returns a value between 0.0 and 1.0, where 1.0 means identical field sets.
4125	///
4126	/// # Examples
4127	///
4128	/// ```rust,ignore
4129	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4130	///
4131	/// let mut from_state = ProjectState::new();
4132	/// let mut from_model = ModelState::new("myapp", "User");
4133	/// from_model.add_field(FieldState::new("user_id", FieldType::Integer, false));
4134	/// from_model.add_field(FieldState::new("user_email", FieldType::VarChar(255), false));
4135	/// from_state.add_model(from_model);
4136	///
4137	/// let mut to_state = ProjectState::new();
4138	/// let mut to_model = ModelState::new("auth", "User");
4139	/// to_model.add_field(FieldState::new("id", FieldType::Integer, false));
4140	/// to_model.add_field(FieldState::new("email", FieldType::VarChar(255), false));
4141	/// to_state.add_model(to_model);
4142	///
4143	/// let detector = MigrationAutodetector::new(from_state, to_state);
4144	/// // Similarity would be high due to fuzzy field name matching
4145	/// ```
4146	fn calculate_model_similarity(&self, from_model: &ModelState, to_model: &ModelState) -> f64 {
4147		if from_model.fields.is_empty() && to_model.fields.is_empty() {
4148			return 1.0;
4149		}
4150
4151		if from_model.fields.is_empty() || to_model.fields.is_empty() {
4152			return 0.0;
4153		}
4154
4155		let mut total_similarity = 0.0;
4156		let total_fields = from_model.fields.len().max(to_model.fields.len());
4157
4158		// Use Hungarian algorithm concept: find best matching between fields
4159		let mut matched_to_fields = std::collections::HashSet::new();
4160
4161		for (from_field_name, from_field) in &from_model.fields {
4162			let mut best_match_score = 0.0;
4163			let mut best_match_name = None;
4164
4165			// Find best matching field in to_model
4166			for (to_field_name, to_field) in &to_model.fields {
4167				if matched_to_fields.contains(to_field_name) {
4168					continue;
4169				}
4170
4171				let similarity = self.calculate_field_similarity(
4172					from_field_name,
4173					to_field_name,
4174					from_field,
4175					to_field,
4176				);
4177
4178				if similarity > best_match_score {
4179					best_match_score = similarity;
4180					best_match_name = Some(to_field_name.clone());
4181				}
4182			}
4183
4184			if let Some(matched_name) = best_match_name {
4185				matched_to_fields.insert(matched_name);
4186				total_similarity += best_match_score;
4187			}
4188		}
4189
4190		total_similarity / total_fields as f64
4191	}
4192
4193	/// Calculate field-level similarity using hybrid algorithm
4194	///
4195	/// This method combines Jaro-Winkler and Levenshtein distance to measure
4196	/// similarity between field names, providing better detection than either alone.
4197	///
4198	/// # Hybrid Algorithm
4199	/// - **Jaro-Winkler**: Best for prefix similarities (e.g., "UserEmail" vs "UserAddress")
4200	///   - Time Complexity: O(n)
4201	///   - Range: 0.0 to 1.0
4202	///   - Default weight: 70%
4203	/// - **Levenshtein**: Best for edit distance (e.g., "User" vs "Users")
4204	///   - Time Complexity: O(n*m)
4205	///   - Normalized to 0.0-1.0 range
4206	///   - Default weight: 30%
4207	///
4208	/// # Examples
4209	///
4210	/// ```rust,ignore
4211	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4212	///
4213	/// let from_state = ProjectState::new();
4214	/// let to_state = ProjectState::new();
4215	/// let detector = MigrationAutodetector::new(from_state, to_state);
4216	///
4217	/// let from_field = FieldState::new("user_email", FieldType::VarChar(255), false);
4218	/// let to_field = FieldState::new("email", FieldType::VarChar(255), false);
4219	///
4220	/// // High similarity (field name is similar and type matches)
4221	/// // Jaro-Winkler ≈ 0.81, Levenshtein normalized ≈ 0.45
4222	/// // Hybrid (0.7 * 0.81 + 0.3 * 0.45) ≈ 0.70
4223	/// ```
4224	fn calculate_field_similarity(
4225		&self,
4226		from_field_name: &str,
4227		to_field_name: &str,
4228		from_field: &FieldState,
4229		to_field: &FieldState,
4230	) -> f64 {
4231		// If types don't match, similarity is 0
4232		if from_field.field_type != to_field.field_type {
4233			return 0.0;
4234		}
4235
4236		// Calculate Jaro-Winkler similarity (0.0 - 1.0)
4237		let jaro_winkler_sim = jaro_winkler(from_field_name, to_field_name);
4238
4239		// Calculate Levenshtein distance and normalize to 0.0-1.0
4240		let lev_distance = levenshtein(from_field_name, to_field_name);
4241		let max_len = from_field_name.len().max(to_field_name.len()) as f64;
4242		let levenshtein_sim = if max_len > 0.0 {
4243			1.0 - (lev_distance as f64 / max_len)
4244		} else {
4245			1.0 // Both strings are empty
4246		};
4247
4248		// Combine using configured weights
4249		let name_similarity = self.similarity_config.jaro_winkler_weight * jaro_winkler_sim
4250			+ self.similarity_config.levenshtein_weight * levenshtein_sim;
4251
4252		// Boost similarity if nullability also matches
4253		let nullable_boost = if from_field.nullable == to_field.nullable {
4254			0.1
4255		} else {
4256			0.0
4257		};
4258
4259		(name_similarity + nullable_boost).min(1.0)
4260	}
4261
4262	/// Perform bipartite matching between deleted and created models
4263	///
4264	/// # Algorithm: Maximum Weight Bipartite Matching
4265	/// - Based on Hopcroft-Karp algorithm concept: O(n*m*√(n+m))
4266	/// - Uses petgraph for graph construction
4267	/// - Finds optimal matching considering all possible pairs
4268	///
4269	/// # Implementation Note
4270	/// This implementation uses a greedy approach with weighted edges sorted by
4271	/// similarity score. While not a full Hopcroft-Karp implementation, it provides
4272	/// good results with O(E log E) complexity where E = number of edges.
4273	///
4274	/// # Returns
4275	/// Vector of matches: (deleted_key, created_key, similarity_score)
4276	///
4277	/// # Examples
4278	///
4279	/// ```rust,ignore
4280	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4281	///
4282	/// let mut from_state = ProjectState::new();
4283	/// let mut old_model = ModelState::new("myapp", "User");
4284	/// old_model.add_field(FieldState::new("id", FieldType::Integer, false));
4285	/// from_state.add_model(old_model);
4286	///
4287	/// let mut to_state = ProjectState::new();
4288	/// let mut new_model = ModelState::new("auth", "User");
4289	/// new_model.add_field(FieldState::new("id", FieldType::Integer, false));
4290	/// to_state.add_model(new_model);
4291	///
4292	/// let detector = MigrationAutodetector::new(from_state, to_state);
4293	/// // Would detect cross-app model move from myapp.User to auth.User
4294	/// ```
4295	fn find_optimal_model_matches(
4296		&self,
4297		deleted: &[&(String, String)],
4298		created: &[&(String, String)],
4299	) -> Vec<ModelMatchResult> {
4300		let mut graph = Graph::<(), f64, Undirected>::new_undirected();
4301		let mut deleted_nodes = Vec::new();
4302		let mut created_nodes = Vec::new();
4303
4304		// Create nodes for deleted models (left side of bipartite graph)
4305		for _ in deleted {
4306			deleted_nodes.push(graph.add_node(()));
4307		}
4308
4309		// Create nodes for created models (right side of bipartite graph)
4310		for _ in created {
4311			created_nodes.push(graph.add_node(()));
4312		}
4313
4314		// Add edges with similarity weights
4315		for (i, deleted_key) in deleted.iter().enumerate() {
4316			if let Some(from_model) = self.from_state.models.get(*deleted_key) {
4317				for (j, created_key) in created.iter().enumerate() {
4318					if let Some(to_model) = self.to_state.models.get(*created_key) {
4319						let similarity = self.calculate_model_similarity(from_model, to_model);
4320
4321						// Only add edge if similarity exceeds threshold
4322						if similarity >= self.similarity_config.model_threshold() {
4323							graph.add_edge(deleted_nodes[i], created_nodes[j], similarity);
4324						}
4325					}
4326				}
4327			}
4328		}
4329
4330		// Find maximum weight matching using greedy algorithm
4331		// (Full Hopcroft-Karp would require additional implementation)
4332		let mut matches = Vec::new();
4333		let mut used_deleted = std::collections::HashSet::new();
4334		let mut used_created = std::collections::HashSet::new();
4335
4336		// Sort edges by weight (similarity) in descending order
4337		let mut weighted_edges: Vec<_> = graph
4338			.edge_references()
4339			.map(|e| (e.source(), e.target(), *e.weight()))
4340			.collect();
4341		weighted_edges.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
4342
4343		// Greedy matching: pick highest weight edges first
4344		for (source, target, weight) in weighted_edges {
4345			let source_idx = deleted_nodes.iter().position(|&n| n == source);
4346			let target_idx = created_nodes.iter().position(|&n| n == target);
4347
4348			if let (Some(i), Some(j)) = (source_idx, target_idx)
4349				&& !used_deleted.contains(&i)
4350				&& !used_created.contains(&j)
4351			{
4352				matches.push((deleted[i].clone(), created[j].clone(), weight));
4353				used_deleted.insert(i);
4354				used_created.insert(j);
4355			}
4356		}
4357
4358		matches
4359	}
4360
4361	/// Detect added indexes
4362	///
4363	/// # Django Reference
4364	/// From: django/db/migrations/autodetector.py:1500-1600
4365	fn detect_added_indexes(&self, changes: &mut DetectedChanges) {
4366		for ((app_label, model_name), to_model) in &self.to_state.models {
4367			if let Some(from_model) = self
4368				.from_state
4369				.get_model_by_table_name(app_label, &to_model.table_name)
4370			{
4371				for to_index in &to_model.indexes {
4372					// Check if this index exists in from_model
4373					if !from_model
4374						.indexes
4375						.iter()
4376						.any(|idx| idx.name == to_index.name)
4377					{
4378						changes.added_indexes.push((
4379							app_label.clone(),
4380							model_name.clone(),
4381							to_index.clone(),
4382						));
4383					}
4384				}
4385			}
4386		}
4387	}
4388
4389	/// Detect removed indexes
4390	///
4391	/// # Django Reference
4392	/// From: django/db/migrations/autodetector.py:1600-1700
4393	fn detect_removed_indexes(&self, changes: &mut DetectedChanges) {
4394		for ((app_label, model_name), from_model) in &self.from_state.models {
4395			if let Some(to_model) = self
4396				.to_state
4397				.get_model_by_table_name(app_label, &from_model.table_name)
4398			{
4399				for from_index in &from_model.indexes {
4400					// Check if this index still exists in to_model
4401					if !to_model
4402						.indexes
4403						.iter()
4404						.any(|idx| idx.name == from_index.name)
4405					{
4406						changes.removed_indexes.push((
4407							app_label.clone(),
4408							model_name.clone(),
4409							from_index.name.clone(),
4410						));
4411					}
4412				}
4413			}
4414		}
4415	}
4416
4417	/// Detect added constraints
4418	///
4419	/// A single-field UNIQUE constraint on the to-side is treated as
4420	/// already-present on the from-side when any of the following is true:
4421	///
4422	/// 1. From-state has a constraint with the same name (legacy behaviour).
4423	/// 2. From-state has any single-field UNIQUE constraint covering the same
4424	///    column — same semantics, different name. This handles the
4425	///    DB-introspection case where SQLite auto-generates names like
4426	///    `sqlite_autoindex_users_1`, which never match the to-state's
4427	///    `{app}_{model}_{field}_uniq`.
4428	/// 3. From-state has a `FieldState` for that column with
4429	///    `params["unique"] == "true"`. This handles the file-based
4430	///    reconstruction path: `apply_migration_operations` translates
4431	///    `ColumnDefinition.unique = true` into an inline field param but
4432	///    never synthesises a peer `ConstraintDefinition`, while
4433	///    `ModelMetadata::to_model_state()` on the to-side does synthesise
4434	///    one. Without this branch the autodetector keeps emitting a
4435	///    redundant `AddConstraint` every time `makemigrations` runs against
4436	///    a model whose `#[field(unique = true)]` column already shipped in
4437	///    `0001_initial.rs` (see reinhardt-web#4448).
4438	///
4439	/// # Django Reference
4440	/// From: django/db/migrations/autodetector.py:1700-1800
4441	fn detect_added_constraints(&self, changes: &mut DetectedChanges) {
4442		for ((app_label, model_name), to_model) in &self.to_state.models {
4443			if let Some(from_model) = self
4444				.from_state
4445				.get_model_by_table_name(app_label, &to_model.table_name)
4446			{
4447				for to_constraint in &to_model.constraints {
4448					if from_model
4449						.constraints
4450						.iter()
4451						.any(|c| c.name == to_constraint.name)
4452					{
4453						continue;
4454					}
4455					if Self::single_field_unique_already_present(to_constraint, from_model) {
4456						continue;
4457					}
4458					changes.added_constraints.push((
4459						app_label.clone(),
4460						model_name.clone(),
4461						to_constraint.clone(),
4462					));
4463				}
4464			}
4465		}
4466	}
4467
4468	/// Detect removed constraints
4469	///
4470	/// Symmetric to [`Self::detect_added_constraints`]: a from-side
4471	/// single-field UNIQUE constraint is NOT reported as removed when the
4472	/// to-side carries an equivalent shape — either another single-field
4473	/// UNIQUE constraint over the same column, or a `FieldState` for that
4474	/// column with `params["unique"] == "true"`. Without this guard the
4475	/// asymmetric shape-match in `detect_added_constraints` would simply move
4476	/// the redundancy from `AddConstraint` into a spurious `DropConstraint`
4477	/// when the column was originally introduced as a separately-named
4478	/// UNIQUE constraint but is now declared via inline `#[field(unique =
4479	/// true)]` (or vice versa). See reinhardt-web#4448.
4480	///
4481	/// # Django Reference
4482	/// From: django/db/migrations/autodetector.py:1800-1900
4483	fn detect_removed_constraints(&self, changes: &mut DetectedChanges) {
4484		for ((app_label, model_name), from_model) in &self.from_state.models {
4485			if let Some(to_model) = self
4486				.to_state
4487				.get_model_by_table_name(app_label, &from_model.table_name)
4488			{
4489				for from_constraint in &from_model.constraints {
4490					if to_model
4491						.constraints
4492						.iter()
4493						.any(|c| c.name == from_constraint.name)
4494					{
4495						continue;
4496					}
4497					if Self::single_field_unique_already_present(from_constraint, to_model) {
4498						continue;
4499					}
4500					changes.removed_constraints.push((
4501						app_label.clone(),
4502						model_name.clone(),
4503						from_constraint.name.clone(),
4504					));
4505				}
4506			}
4507		}
4508	}
4509
4510	/// Returns true when `candidate` is a single-field UNIQUE constraint and
4511	/// the same column on `other_side` is already covered by either:
4512	/// - any single-field UNIQUE constraint over the same column, or
4513	/// - a field whose `params["unique"] == "true"`.
4514	///
4515	/// Used by both `detect_added_constraints` and
4516	/// `detect_removed_constraints` to recognise inline `column.unique = true`
4517	/// and a separately-named single-field `UNIQUE` constraint as
4518	/// semantically identical, so a name mismatch alone does not trigger a
4519	/// redundant `AddConstraint` / `DropConstraint` (reinhardt-web#4448).
4520	fn single_field_unique_already_present(
4521		candidate: &ConstraintDefinition,
4522		other_side: &ModelState,
4523	) -> bool {
4524		if !is_single_field_unique(candidate) {
4525			return false;
4526		}
4527		let column = &candidate.fields[0];
4528		let covered_by_constraint = other_side
4529			.constraints
4530			.iter()
4531			.any(|c| is_single_field_unique(c) && &c.fields[0] == column);
4532		if covered_by_constraint {
4533			return true;
4534		}
4535		other_side
4536			.fields
4537			.get(column)
4538			.and_then(|f| f.params.get("unique"))
4539			.map(String::as_str)
4540			== Some("true")
4541	}
4542
4543	/// Final-pass dedup: drop redundant single-column `AddConstraint UNIQUE`
4544	/// operations whose column is already declared unique elsewhere in the
4545	/// same migration.
4546	///
4547	/// This is the second of two layers that protect against the bug in
4548	/// reinhardt-web#4448. The primary fix is `detect_added_constraints`'s
4549	/// shape-match, which compares from-state and to-state. This pass
4550	/// inspects the *generated* operation list and is the safety net for
4551	/// any future codepath that produces both an `AddColumn { column.unique
4552	/// = true }` and a peer `AddConstraint` for the same single column —
4553	/// for example, a column being added in the same migration as the
4554	/// model registry synthesises its `{app}_{model}_{field}_uniq`
4555	/// constraint.
4556	///
4557	/// Coverage rules (per `(table, column)`):
4558	/// - `Operation::CreateTable { name, columns, constraints }` —
4559	///   any column with `unique = true` or any `Constraint::Unique` over a
4560	///   single column counts the column as already unique.
4561	/// - `Operation::AddColumn { table, column }` — `column.unique = true`
4562	///   counts.
4563	/// - A previously-emitted `Operation::AddConstraint` whose SQL is a
4564	///   single-column UNIQUE on the same column also counts, so duplicate
4565	///   `AddConstraint`s for the same column in the same op list collapse
4566	///   to one.
4567	///
4568	/// Multi-column UNIQUE (`unique_together`) is intentionally not touched
4569	/// — its semantics differ from a single-column UNIQUE.
4570	fn dedup_redundant_unique_add_constraints(
4571		by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
4572	) {
4573		use std::collections::HashSet;
4574
4575		for operations in by_app.values_mut() {
4576			// (table, column) pairs already known to be UNIQUE in this migration.
4577			let mut covered: HashSet<(String, String)> = HashSet::new();
4578			let mut keep = Vec::with_capacity(operations.len());
4579			for op in operations.drain(..) {
4580				match &op {
4581					super::Operation::CreateTable {
4582						name,
4583						columns,
4584						constraints,
4585						..
4586					} => {
4587						for col in columns {
4588							if col.unique {
4589								covered.insert((name.clone(), col.name.clone()));
4590							}
4591						}
4592						for c in constraints {
4593							if let super::operations::Constraint::Unique { columns, .. } = c
4594								&& columns.len() == 1
4595							{
4596								covered.insert((name.clone(), columns[0].clone()));
4597							}
4598						}
4599						keep.push(op);
4600					}
4601					super::Operation::AddColumn { table, column, .. } => {
4602						if column.unique {
4603							covered.insert((table.clone(), column.name.clone()));
4604						}
4605						keep.push(op);
4606					}
4607					super::Operation::AddConstraint {
4608						table,
4609						constraint_sql,
4610					} => {
4611						if let Some(col) = parse_single_column_unique(constraint_sql) {
4612							let key = (table.clone(), col.to_string());
4613							if covered.contains(&key) {
4614								// Redundant — drop it.
4615								continue;
4616							}
4617							covered.insert(key);
4618						}
4619						keep.push(op);
4620					}
4621					_ => keep.push(op),
4622				}
4623			}
4624			*operations = keep;
4625		}
4626	}
4627
4628	/// Detect added and modified composite primary keys (2+ columns).
4629	///
4630	/// A composite PK is represented as a `ConstraintDefinition` with
4631	/// `constraint_type == "primary_key"` and `fields.len() >= 2`.
4632	///
4633	/// Three cases are handled:
4634	/// - Added: no constraint with the same name existed in from_state → emit CreateCompositePrimaryKey
4635	/// - Modified: same constraint name exists but fields differ → emit DropConstraint + CreateCompositePrimaryKey
4636	/// - Unchanged: same constraint name and identical fields → no operation
4637	fn detect_composite_pk_changes(&self, changes: &mut DetectedChanges) {
4638		for ((app_label, model_name), to_model) in &self.to_state.models {
4639			let from_model = self
4640				.from_state
4641				.get_model_by_table_name(app_label, &to_model.table_name);
4642			for constraint in &to_model.constraints {
4643				if constraint.constraint_type != "primary_key" || constraint.fields.len() < 2 {
4644					continue;
4645				}
4646				let from_pk = from_model
4647					.and_then(|m| m.constraints.iter().find(|c| c.name == constraint.name));
4648				match from_pk {
4649					Some(existing) if existing.fields == constraint.fields => {
4650						// Unchanged — no operation needed
4651					}
4652					Some(_) => {
4653						// Modified (same name, different fields) — drop old then create new
4654						changes.removed_composite_primary_keys.push((
4655							app_label.clone(),
4656							model_name.clone(),
4657							constraint.name.clone(),
4658						));
4659						changes.added_composite_primary_keys.push((
4660							app_label.clone(),
4661							model_name.clone(),
4662							constraint.clone(),
4663						));
4664					}
4665					None => {
4666						// Added — create new
4667						changes.added_composite_primary_keys.push((
4668							app_label.clone(),
4669							model_name.clone(),
4670							constraint.clone(),
4671						));
4672					}
4673				}
4674			}
4675		}
4676	}
4677
4678	/// Detect auto-increment sequence resets driven by `sequence_reset` model option.
4679	///
4680	/// When `ModelState.options["sequence_reset"]` is added or changed, emit a
4681	/// `SetAutoIncrementValue` operation targeting the model's auto-increment column.
4682	fn detect_auto_increment_resets(&self, changes: &mut DetectedChanges) {
4683		for ((app_label, model_name), to_model) in &self.to_state.models {
4684			let Some(value_str) = to_model.options.get("sequence_reset") else {
4685				continue;
4686			};
4687			let from_value = self
4688				.from_state
4689				.get_model(app_label, model_name)
4690				.and_then(|m| m.options.get("sequence_reset"))
4691				.map(String::as_str);
4692			if from_value == Some(value_str.as_str()) {
4693				continue;
4694			}
4695			let Ok(value) = value_str.parse::<i64>() else {
4696				eprintln!(
4697					"Invalid sequence_reset value for {}.{}: {:?}. Expected an integer.",
4698					app_label, model_name, value_str
4699				);
4700				continue;
4701			};
4702			let Some(column) = to_model
4703				.fields
4704				.iter()
4705				.find(|(_, f)| f.params.get("auto_increment").is_some_and(|v| v == "true"))
4706				.map(|(name, _)| name.clone())
4707			else {
4708				continue;
4709			};
4710			changes.auto_increment_resets.push((
4711				app_label.clone(),
4712				model_name.clone(),
4713				column,
4714				value,
4715			));
4716		}
4717	}
4718
4719	/// Generate intermediate table operation for ManyToMany field
4720	///
4721	/// Creates a through table for ManyToMany relationships with:
4722	/// - id: BigInteger primary key with auto_increment
4723	/// - {source}_id: BigInteger foreign key to source model
4724	/// - {target}_id: BigInteger foreign key to target model
4725	/// - Unique constraint on (source_id, target_id)
4726	///
4727	/// # Arguments
4728	/// * `app_label` - The app label of the source model
4729	/// * `model_name` - The source model name
4730	/// * `field_name` - The ManyToMany field name
4731	/// * `to_model` - The target model reference (e.g., "app.Model")
4732	/// * `through_table` - Optional custom through table name
4733	///
4734	/// # Returns
4735	/// Optional CreateTable operation for the intermediate table
4736	fn generate_intermediate_table(
4737		&self,
4738		app_label: &str,
4739		model_name: &str,
4740		field_name: &str,
4741		to_model: &str,
4742		through_table: &Option<String>,
4743	) -> Option<super::Operation> {
4744		// Resolve the source table name from to_state. The source model is
4745		// guaranteed to be in to_state because this function is called from
4746		// `generate_operations` while iterating `to_state.models`.
4747		let source_table = self
4748			.to_state
4749			.get_model(app_label, model_name)
4750			.map(|m| m.table_name.clone())
4751			.unwrap_or_else(|| {
4752				format!("{}_{}", to_snake_case(app_label), to_snake_case(model_name))
4753			});
4754
4755		// Parse target model to get its app and table name
4756		let (target_app, target_model) = self.parse_model_reference(to_model, app_label)?;
4757		let target_table = self
4758			.to_state
4759			.get_model(&target_app, &target_model)
4760			.map(|m| m.table_name.clone())
4761			.or_else(|| {
4762				super::model_registry::global_registry()
4763					.get_models()
4764					.iter()
4765					.find(|m| m.app_label == target_app && m.model_name == target_model)
4766					.map(|m| m.table_name.clone())
4767			})
4768			.unwrap_or_else(|| format!("{}_{}", target_app, to_snake_case(&target_model)));
4769
4770		// Generate through-table name: prefer explicit `through`, otherwise
4771		// derive from the source table name (matches
4772		// `create_intermediate_table_for_m2m` and the ORM accessor's
4773		// `default_through_table`, which both lowercase the source table
4774		// before composition — see #4659).
4775		let table_name = if let Some(custom_name) = through_table {
4776			custom_name.clone()
4777		} else {
4778			format!(
4779				"{}_{}",
4780				source_table.to_lowercase(),
4781				to_snake_case(field_name)
4782			)
4783		};
4784
4785		// Derive column names from the resolved *table* names so the
4786		// autodetector matches the ORM accessor convention
4787		// (`format!("{}_id", T::table_name().to_lowercase())` in
4788		// `crates/reinhardt-db/src/orm/many_to_many_accessor.rs`). Compare by
4789		// table identity for self-reference (#4659 follow-up).
4790		let source_table_lower = source_table.to_lowercase();
4791		let target_table_lower = target_table.to_lowercase();
4792		let (source_column, target_column) = if source_table_lower == target_table_lower {
4793			(
4794				format!("from_{}_id", source_table_lower),
4795				format!("to_{}_id", target_table_lower),
4796			)
4797		} else {
4798			(
4799				format!("{}_id", source_table_lower),
4800				format!("{}_id", target_table_lower),
4801			)
4802		};
4803
4804		// Resolve real PK types on both sides so the junction table matches
4805		// what `create_intermediate_table_for_m2m` / `generate_migrations`
4806		// produce. Without this, FK columns would hard-code `BigInteger`
4807		// even when either side uses a different PK type.
4808		let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
4809		let target_pk_type = self
4810			.to_state
4811			.get_primary_key_type(&target_app, &target_model);
4812
4813		// Create columns
4814		let columns = vec![
4815			// id column
4816			super::ColumnDefinition {
4817				name: "id".to_string(),
4818				type_definition: super::FieldType::BigInteger,
4819				not_null: true,
4820				unique: false,
4821				primary_key: true,
4822				auto_increment: true,
4823				default: None,
4824			},
4825			// source_id column
4826			super::ColumnDefinition {
4827				name: source_column.clone(),
4828				type_definition: source_pk_type,
4829				not_null: true,
4830				unique: false,
4831				primary_key: false,
4832				auto_increment: false,
4833				default: None,
4834			},
4835			// target_id column
4836			super::ColumnDefinition {
4837				name: target_column.clone(),
4838				type_definition: target_pk_type,
4839				not_null: true,
4840				unique: false,
4841				primary_key: false,
4842				auto_increment: false,
4843				default: None,
4844			},
4845		];
4846
4847		// Create constraints (use the resolved table names)
4848		let constraints = vec![
4849			// Foreign key to source table
4850			super::Constraint::ForeignKey {
4851				name: format!("fk_{}_{}", table_name, source_column),
4852				columns: vec![source_column.clone()],
4853				referenced_table: source_table.clone(),
4854				referenced_columns: vec!["id".to_string()],
4855				on_delete: super::ForeignKeyAction::Cascade,
4856				on_update: super::ForeignKeyAction::Cascade,
4857				deferrable: None,
4858			},
4859			// Foreign key to target table
4860			super::Constraint::ForeignKey {
4861				name: format!("fk_{}_{}", table_name, target_column),
4862				columns: vec![target_column.clone()],
4863				referenced_table: target_table.clone(),
4864				referenced_columns: vec!["id".to_string()],
4865				on_delete: super::ForeignKeyAction::Cascade,
4866				on_update: super::ForeignKeyAction::Cascade,
4867				deferrable: None,
4868			},
4869			// Unique constraint on (source_id, target_id)
4870			super::Constraint::Unique {
4871				name: format!(
4872					"uq_{}_{}_{}",
4873					table_name,
4874					source_column.replace("_id", ""),
4875					target_column.replace("_id", "")
4876				),
4877				columns: vec![source_column, target_column],
4878			},
4879		];
4880
4881		Some(super::Operation::CreateTable {
4882			name: table_name,
4883			columns,
4884			constraints,
4885			without_rowid: None,
4886			interleave_in_parent: None,
4887			partition: None,
4888		})
4889	}
4890
4891	/// Generate operations from detected changes
4892	///
4893	/// Converts DetectedChanges into a list of Operation objects that can be
4894	/// executed to migrate the database schema.
4895	///
4896	/// # Django Reference
4897	/// From: django/db/migrations/autodetector.py:1063-1164
4898	/// ```python
4899	/// def generate_created_models(self):
4900	///     for app_label, model_name in sorted(self.new_model_keys):
4901	///         model_state = self.to_state.models[app_label, model_name]
4902	///         self.add_operation(
4903	///             app_label,
4904	///             operations.CreateModel(
4905	///                 name=model_name,
4906	///                 fields=model_state.fields,
4907	///                 options=model_state.options,
4908	///                 bases=model_state.bases,
4909	///             ),
4910	///         )
4911	/// ```rust,ignore
4912	///
4913	/// # Examples
4914	///
4915	/// ```rust,ignore
4916	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
4917	///
4918	/// let mut from_state = ProjectState::new();
4919	/// let mut to_state = ProjectState::new();
4920	///
4921	/// // Add a new model to the target state
4922	/// let mut model = ModelState::new("myapp", "User");
4923	/// model.add_field(FieldState::new("id", FieldType::Integer, false));
4924	/// to_state.add_model(model);
4925	///
4926	/// let detector = MigrationAutodetector::new(from_state, to_state);
4927	/// let operations = detector.generate_operations();
4928	///
4929	/// assert!(!operations.is_empty());
4930	/// ```rust,ignore
4931	/// Sort operations by their dependencies to ensure correct execution order
4932	///
4933	/// This method reorders operations to prevent execution errors:
4934	/// 1. CreateTable operations first (tables must exist before modification)
4935	/// 2. AddColumn/AlterColumn operations next (field modifications)
4936	/// 3. Other operations last (indexes, constraints, etc.)
4937	fn sort_operations_by_dependency(
4938		&self,
4939		mut operations: Vec<super::Operation>,
4940	) -> Vec<super::Operation> {
4941		let mut sorted = Vec::new();
4942
4943		// Extract CreateTable operations (must be first)
4944		let create_tables: Vec<_> = operations
4945			.iter()
4946			.filter(|op| matches!(op, super::Operation::CreateTable { .. }))
4947			.cloned()
4948			.collect();
4949		operations.retain(|op| !matches!(op, super::Operation::CreateTable { .. }));
4950
4951		// Extract field operations (must be after CreateTable)
4952		let field_ops: Vec<_> = operations
4953			.iter()
4954			.filter(|op| {
4955				matches!(
4956					op,
4957					super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
4958				)
4959			})
4960			.cloned()
4961			.collect();
4962		operations.retain(|op| {
4963			!matches!(
4964				op,
4965				super::Operation::AddColumn { .. } | super::Operation::AlterColumn { .. }
4966			)
4967		});
4968
4969		// Assemble in correct order
4970		sorted.extend(create_tables);
4971		sorted.extend(field_ops);
4972		sorted.extend(operations); // Remaining operations
4973
4974		sorted
4975	}
4976
4977	/// Performs the generate operations operation.
4978	pub fn generate_operations(&self) -> Vec<super::Operation> {
4979		let changes = self.detect_changes();
4980		let mut by_app: std::collections::BTreeMap<String, Vec<super::Operation>> =
4981			std::collections::BTreeMap::new();
4982
4983		// Shared per-app emissions (CreateTable, column ops, constraint ops,
4984		// auto-increment resets). This is the single source of truth shared
4985		// with `generate_migrations()` so the two paths cannot diverge again
4986		// (issue #4040).
4987		self.emit_shared_per_app_operations(&changes, &mut by_app);
4988
4989		// `generate_operations()`-specific extra: walk ManyToMany fields on
4990		// new and added models and emit intermediate `CreateTable`s via
4991		// `generate_intermediate_table`. This complements the shared
4992		// emissions above. (`generate_migrations()` covers the same
4993		// ground via `created_many_to_many` with PK-type-resolved logic.)
4994		for (app_label, model_name) in &changes.created_models {
4995			if let Some(model) = self.to_state.get_model(app_label, model_name) {
4996				for (field_name, field_state) in &model.fields {
4997					if let super::FieldType::ManyToMany { to, through } = &field_state.field_type
4998						&& let Some(operation) = self.generate_intermediate_table(
4999							app_label, model_name, field_name, to, through,
5000						) {
5001						by_app.entry(app_label.clone()).or_default().push(operation);
5002					}
5003				}
5004			}
5005		}
5006		for (app_label, model_name, field_name) in &changes.added_fields {
5007			if let Some(model) = self.to_state.get_model(app_label, model_name)
5008				&& let Some(field) = model.get_field(field_name)
5009				&& let super::FieldType::ManyToMany { to, through } = &field.field_type
5010				&& let Some(operation) =
5011					self.generate_intermediate_table(app_label, model_name, field_name, to, through)
5012			{
5013				by_app.entry(app_label.clone()).or_default().push(operation);
5014			}
5015		}
5016
5017		// Note: MoveModel and RenameTable operations are intentionally only
5018		// emitted by `generate_migrations()` (not here). Direct callers of
5019		// `generate_operations()` historically did not see them; preserve
5020		// that contract to avoid behavioral surprises.
5021
5022		// Second-line defence against redundant single-column `AddConstraint
5023		// UNIQUE` operations. The primary fix lives in
5024		// `detect_added_constraints` (shape-match), but this pass also catches
5025		// cases where the column is being added in the same migration with
5026		// `column.unique = true` *and* a peer `AddConstraint` is emitted for
5027		// it. See reinhardt-web#4448.
5028		Self::dedup_redundant_unique_add_constraints(&mut by_app);
5029
5030		// Flatten and sort by dependency to ensure correct execution order.
5031		let operations: Vec<super::Operation> = by_app.into_values().flatten().collect();
5032		self.sort_operations_by_dependency(operations)
5033	}
5034
5035	/// Emit per-app operations shared by `generate_operations()` and
5036	/// `generate_migrations()`.
5037	///
5038	/// This is the single source of truth for emissions that previously had
5039	/// to be duplicated between the two methods. Issue #4040 was caused by
5040	/// PR #3998 updating only `generate_operations()` while
5041	/// `generate_migrations()` (the CLI entry point) was left silently
5042	/// divergent. Centralizing the shared emissions here makes that class of
5043	/// drift impossible.
5044	///
5045	/// Method-specific extras (M2M field-walking for `generate_operations()`;
5046	/// `created_many_to_many` / `renamed_models` / `moved_models` for
5047	/// `generate_migrations()`) are added by the callers after this helper
5048	/// returns.
5049	fn emit_shared_per_app_operations(
5050		&self,
5051		changes: &DetectedChanges,
5052		by_app: &mut std::collections::BTreeMap<String, Vec<super::Operation>>,
5053	) {
5054		// CreateTable for new models.
5055		for (app_label, model_name) in &changes.created_models {
5056			if let Some(model) = self.to_state.get_model(app_label, model_name) {
5057				let mut columns = Vec::new();
5058				for (field_name, field_state) in &model.fields {
5059					columns.push(super::ColumnDefinition::from_field_state(
5060						field_name.clone(),
5061						field_state,
5062					));
5063				}
5064
5065				let constraints: Vec<super::operations::Constraint> = model
5066					.constraints
5067					.iter()
5068					.map(|c| c.to_constraint())
5069					.collect();
5070
5071				by_app
5072					.entry(app_label.clone())
5073					.or_default()
5074					.push(super::Operation::CreateTable {
5075						name: model.table_name.clone(),
5076						columns,
5077						constraints,
5078						without_rowid: None,
5079						interleave_in_parent: None,
5080						partition: None,
5081					});
5082			}
5083		}
5084
5085		// AddColumn for new fields.
5086		//
5087		// Use `model.table_name` (not `model.name`) so the executor's
5088		// `find_model_by_table_mut(table)` path resolves correctly. The
5089		// previous `generate_operations()` body used `model.name` here, which
5090		// was a latent bug that did not surface because that path was rarely
5091		// exercised against table-name-keyed state.
5092		for (app_label, model_name, field_name) in &changes.added_fields {
5093			if let Some(model) = self.to_state.get_model(app_label, model_name)
5094				&& let Some(field) = model.get_field(field_name)
5095			{
5096				by_app
5097					.entry(app_label.clone())
5098					.or_default()
5099					.push(super::Operation::AddColumn {
5100						table: model.table_name.clone(),
5101						column: super::ColumnDefinition::from_field_state(
5102							field_name.clone(),
5103							field,
5104						),
5105						mysql_options: None,
5106					});
5107			}
5108		}
5109
5110		// AlterColumn for changed fields.
5111		for (app_label, model_name, field_name) in &changes.altered_fields {
5112			if let Some(model) = self.to_state.get_model(app_label, model_name)
5113				&& let Some(field) = model.get_field(field_name)
5114			{
5115				by_app
5116					.entry(app_label.clone())
5117					.or_default()
5118					.push(super::Operation::AlterColumn {
5119						table: model.table_name.clone(),
5120						old_definition: None,
5121						column: field_name.clone(),
5122						new_definition: super::ColumnDefinition::from_field_state(
5123							field_name.clone(),
5124							field,
5125						),
5126						mysql_options: None,
5127					});
5128			}
5129		}
5130
5131		// DropColumn for removed fields.
5132		for (app_label, model_name, field_name) in &changes.removed_fields {
5133			if let Some(model) = self.from_state.get_model(app_label, model_name) {
5134				by_app
5135					.entry(app_label.clone())
5136					.or_default()
5137					.push(super::Operation::DropColumn {
5138						table: model.table_name.clone(),
5139						column: field_name.clone(),
5140					});
5141			}
5142		}
5143
5144		// DropTable for deleted models.
5145		for (app_label, model_name) in &changes.deleted_models {
5146			if let Some(model) = self.from_state.get_model(app_label, model_name) {
5147				by_app
5148					.entry(app_label.clone())
5149					.or_default()
5150					.push(super::Operation::DropTable {
5151						name: model.table_name.clone(),
5152					});
5153			}
5154		}
5155
5156		// DropConstraint for modified composite PKs (drop before recreate).
5157		for (app_label, model_name, constraint_name) in &changes.removed_composite_primary_keys {
5158			if let Some(model) = self.from_state.get_model(app_label, model_name) {
5159				by_app.entry(app_label.clone()).or_default().push(
5160					super::Operation::DropConstraint {
5161						table: model.table_name.clone(),
5162						constraint_name: constraint_name.clone(),
5163					},
5164				);
5165			}
5166		}
5167
5168		// CreateCompositePrimaryKey for composite PK additions.
5169		for (app_label, model_name, constraint) in &changes.added_composite_primary_keys {
5170			if let Some(model) = self.to_state.get_model(app_label, model_name) {
5171				by_app.entry(app_label.clone()).or_default().push(
5172					super::Operation::CreateCompositePrimaryKey {
5173						table: model.table_name.clone(),
5174						columns: constraint.fields.clone(),
5175						constraint_name: Some(constraint.name.clone()),
5176					},
5177				);
5178			}
5179		}
5180
5181		// DropConstraint for non-PK constraints removed from existing tables.
5182		//
5183		// Composite primary keys (constraint_type == "primary_key" with 2+
5184		// fields) are handled by `removed_composite_primary_keys` above and
5185		// must be skipped here to avoid emitting a duplicate DropConstraint.
5186		for (app_label, model_name, constraint_name) in &changes.removed_constraints {
5187			let Some(from_model) = self.from_state.get_model(app_label, model_name) else {
5188				continue;
5189			};
5190			let is_composite_pk = from_model
5191				.constraints
5192				.iter()
5193				.find(|c| &c.name == constraint_name)
5194				.is_some_and(|c| c.constraint_type == "primary_key" && c.fields.len() >= 2);
5195			if is_composite_pk {
5196				continue;
5197			}
5198			by_app
5199				.entry(app_label.clone())
5200				.or_default()
5201				.push(super::Operation::DropConstraint {
5202					table: from_model.table_name.clone(),
5203					constraint_name: constraint_name.clone(),
5204				});
5205		}
5206
5207		// AddConstraint for non-PK constraints added to existing tables.
5208		//
5209		// Covers `unique_together`, `Check`, `ForeignKey`, and `OneToOne`
5210		// constraints declared on a model that already exists in
5211		// `from_state`. Composite primary keys are emitted via
5212		// `added_composite_primary_keys` using `CreateCompositePrimaryKey`
5213		// and must be skipped here to avoid duplicate emission. The
5214		// constraint SQL is rendered through the existing
5215		// `ConstraintDefinition::to_constraint()` -> `Constraint: Display`
5216		// path, which mirrors the SQL produced for the same constraint when
5217		// emitted as part of a `CreateTable` operation, so the on-disk
5218		// schema for a "create + add later" sequence stays equivalent to a
5219		// "create with constraint" sequence.
5220		for (app_label, model_name, constraint) in &changes.added_constraints {
5221			if constraint.constraint_type == "primary_key" && constraint.fields.len() >= 2 {
5222				continue;
5223			}
5224			let Some(to_model) = self.to_state.get_model(app_label, model_name) else {
5225				continue;
5226			};
5227			let constraint_sql = constraint.to_constraint().to_string();
5228			by_app
5229				.entry(app_label.clone())
5230				.or_default()
5231				.push(super::Operation::AddConstraint {
5232					table: to_model.table_name.clone(),
5233					constraint_sql,
5234				});
5235		}
5236
5237		// SetAutoIncrementValue for detected sequence resets.
5238		for (app_label, model_name, column, value) in &changes.auto_increment_resets {
5239			if let Some(model) = self.to_state.get_model(app_label, model_name) {
5240				by_app.entry(app_label.clone()).or_default().push(
5241					super::Operation::SetAutoIncrementValue {
5242						table: model.table_name.clone(),
5243						column: column.clone(),
5244						value: *value,
5245					},
5246				);
5247			}
5248		}
5249	}
5250
5251	/// Generate migrations from detected changes
5252	///
5253	/// Groups operations by app_label and creates Migration objects for each app.
5254	/// This is the final step in the migration autodetection process.
5255	///
5256	/// # Django Reference
5257	/// From: django/db/migrations/autodetector.py:95-141
5258	/// ```python
5259	/// def changes(self, graph, trim_to_apps=None, convert_apps=None, migration_name=None):
5260	///     # Generate operations
5261	///     self._generate_through_model_map()
5262	///     self.generate_renamed_models()
5263	///     # ... all other generate_* methods
5264	///
5265	///     # Group operations by app
5266	///     self.arrange_for_graph(changes, graph, trim_to_apps)
5267	///
5268	///     # Create Migration objects
5269	///     return changes
5270	/// ```rust,ignore
5271	///
5272	/// # Examples
5273	///
5274	/// ```rust,ignore
5275	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
5276	///
5277	/// let mut from_state = ProjectState::new();
5278	/// let mut to_state = ProjectState::new();
5279	///
5280	/// // Add a new model
5281	/// let mut model = ModelState::new("blog", "Post");
5282	/// model.add_field(FieldState::new("title", FieldType::VarChar(255), false));
5283	/// to_state.add_model(model);
5284	///
5285	/// let detector = MigrationAutodetector::new(from_state, to_state);
5286	/// let migrations = detector.generate_migrations();
5287	///
5288	/// assert_eq!(migrations.len(), 1);
5289	/// assert_eq!(migrations[0].app_label, "blog");
5290	/// assert!(!migrations[0].operations.is_empty());
5291	/// ```
5292	pub fn generate_migrations(&self) -> Vec<super::Migration> {
5293		let changes = self.detect_changes();
5294		let mut migrations_by_app: std::collections::BTreeMap<String, Vec<super::Operation>> =
5295			std::collections::BTreeMap::new();
5296
5297		// Shared per-app emissions (CreateTable, column ops, constraint ops,
5298		// auto-increment resets). Single source of truth shared with
5299		// `generate_operations()` — see `emit_shared_per_app_operations` and
5300		// issue #4040.
5301		self.emit_shared_per_app_operations(&changes, &mut migrations_by_app);
5302
5303		// Generate intermediate tables for ManyToMany relationships
5304		for (app_label, model_name, through_table, m2m) in &changes.created_many_to_many {
5305			// Resolve source table name from the to_state model. The
5306			// `table_name` (user-set via `#[model(table_name = "...")]` or
5307			// derived by the macro) is the canonical identifier — never
5308			// the `struct` identifier (see #4659).
5309			let source_table = self
5310				.to_state
5311				.get_model(app_label, model_name)
5312				.map(|m| m.table_name.clone())
5313				.unwrap_or_else(|| format!("{}_{}", app_label, model_name.to_lowercase()));
5314
5315			// Parse the target reference up-front so qualified names like
5316			// "app.Model" resolve correctly throughout the rest of this
5317			// block (table lookup, PK type lookup, and the lowercase
5318			// fallback). Without this, lookups would use the literal
5319			// "app.Model" string as the model name, miss every
5320			// to_state/registry entry, and produce defaults like
5321			// "app.model_id".
5322			let (parsed_target_app, parsed_target_model) = self
5323				.parse_model_reference(&m2m.to_model, app_label)
5324				.unwrap_or_else(|| (app_label.to_string(), m2m.to_model.clone()));
5325
5326			// Resolve target table name: prefer to_state, then global registry,
5327			// finally fall back to the canonical `{app}_{model_lower}` form
5328			// (mirroring the source-table fallback above). The fallback must
5329			// include the parsed app label — emitting only the lowercased
5330			// model name would lose the app prefix that `#[model]` writes
5331			// into the real `table_name`, so FK constraints would point at a
5332			// table that does not exist.
5333			let target_table = self
5334				.to_state
5335				.get_model(&parsed_target_app, &parsed_target_model)
5336				.map(|model| model.table_name.clone())
5337				.or_else(|| {
5338					super::model_registry::global_registry()
5339						.get_models()
5340						.iter()
5341						.find(|m| {
5342							m.app_label == parsed_target_app && m.model_name == parsed_target_model
5343						})
5344						.map(|m| m.table_name.clone())
5345				})
5346				.unwrap_or_else(|| {
5347					format!(
5348						"{}_{}",
5349						parsed_target_app,
5350						parsed_target_model.to_lowercase()
5351					)
5352				});
5353
5354			// Default FK column names come from `crate::m2m_naming::default_m2m_columns`,
5355			// the single source of truth shared with `create_intermediate_table_for_m2m`
5356			// and the ORM accessor (issue #4665). The helper keys off the
5357			// *actual* table names (not struct identifiers) and applies
5358			// `from_/to_` prefixes only for self-referential M2M (#4659).
5359			let (default_source_col, default_target_col) =
5360				crate::m2m_naming::default_m2m_columns(&source_table, &target_table);
5361			let source_column = m2m.source_field.clone().unwrap_or(default_source_col);
5362			let target_column = m2m.target_field.clone().unwrap_or(default_target_col);
5363
5364			// Get source model's primary key type
5365			let source_pk_type = self.to_state.get_primary_key_type(app_label, model_name);
5366
5367			// Get target model's primary key type. Reuse the values parsed
5368			// from `m2m.to_model` at the top of this block so the lookup
5369			// agrees with how `target_table` was resolved (#4659 follow-up).
5370			let target_pk_type = self
5371				.to_state
5372				.get_primary_key_type(&parsed_target_app, &parsed_target_model);
5373
5374			// Create intermediate table columns
5375			let columns = vec![
5376				super::ColumnDefinition {
5377					name: "id".to_string(),
5378					type_definition: super::FieldType::Integer,
5379					not_null: true,
5380					unique: false,
5381					primary_key: true,
5382					auto_increment: true,
5383					default: None,
5384				},
5385				super::ColumnDefinition {
5386					name: source_column.clone(),
5387					type_definition: source_pk_type.clone(),
5388					not_null: true,
5389					unique: false,
5390					primary_key: false,
5391					auto_increment: false,
5392					default: None,
5393				},
5394				super::ColumnDefinition {
5395					name: target_column.clone(),
5396					type_definition: target_pk_type,
5397					not_null: true,
5398					unique: false,
5399					primary_key: false,
5400					auto_increment: false,
5401					default: None,
5402				},
5403			];
5404
5405			// Create FK constraints for the intermediate table
5406			let constraints = vec![
5407				super::operations::Constraint::ForeignKey {
5408					name: format!("fk_{}_{}", through_table, source_column),
5409					columns: vec![source_column.clone()],
5410					referenced_table: source_table.clone(),
5411					referenced_columns: vec!["id".to_string()],
5412					on_delete: ForeignKeyAction::Cascade,
5413					on_update: ForeignKeyAction::Cascade,
5414					deferrable: None,
5415				},
5416				super::operations::Constraint::ForeignKey {
5417					name: format!("fk_{}_{}", through_table, target_column),
5418					columns: vec![target_column.clone()],
5419					referenced_table: target_table,
5420					referenced_columns: vec!["id".to_string()],
5421					on_delete: ForeignKeyAction::Cascade,
5422					on_update: ForeignKeyAction::Cascade,
5423					deferrable: None,
5424				},
5425				// Add unique constraint on the combination of both FK columns
5426				super::operations::Constraint::Unique {
5427					name: format!("{}_unique", through_table),
5428					columns: vec![source_column, target_column],
5429				},
5430			];
5431
5432			migrations_by_app
5433				.entry(app_label.clone())
5434				.or_default()
5435				.push(super::Operation::CreateTable {
5436					name: through_table.clone(),
5437					columns,
5438					constraints,
5439					without_rowid: None,
5440					interleave_in_parent: None,
5441					partition: None,
5442				});
5443		}
5444
5445		// Handle model renames (same app)
5446		for (app_label, old_name, new_name) in &changes.renamed_models {
5447			if let Some(model) = self.to_state.get_model(app_label, new_name) {
5448				// Get the old table name from from_state
5449				let old_table_name = self
5450					.from_state
5451					.get_model(app_label, old_name)
5452					.map(|m| m.table_name.clone())
5453					.unwrap_or_else(|| format!("{}_{}", app_label, old_name.to_lowercase()));
5454
5455				// Defense-in-depth: skip no-op renames where table name is unchanged
5456				if old_table_name != model.table_name {
5457					migrations_by_app
5458						.entry(app_label.clone())
5459						.or_default()
5460						.push(super::Operation::RenameTable {
5461							old_name: old_table_name,
5462							new_name: model.table_name.clone(),
5463						});
5464				}
5465			}
5466		}
5467
5468		// Handle cross-app model moves
5469		// MovedModelInfo: (from_app, to_app, model_name, rename_table, old_table, new_table)
5470		for (from_app, to_app, model_name, rename_table, old_table, new_table) in
5471			&changes.moved_models
5472		{
5473			// Get table names
5474			let old_table_name = old_table.clone().unwrap_or_else(|| {
5475				self.from_state
5476					.get_model(from_app, model_name)
5477					.map(|m| m.table_name.clone())
5478					.unwrap_or_else(|| format!("{}_{}", from_app, model_name.to_lowercase()))
5479			});
5480
5481			let new_table_name = new_table.clone().unwrap_or_else(|| {
5482				self.to_state
5483					.get_model(to_app, model_name)
5484					.map(|m| m.table_name.clone())
5485					.unwrap_or_else(|| format!("{}_{}", to_app, model_name.to_lowercase()))
5486			});
5487
5488			// Add MoveModel operation to the target app's migrations
5489			migrations_by_app.entry(to_app.clone()).or_default().push(
5490				super::Operation::MoveModel {
5491					model_name: model_name.clone(),
5492					from_app: from_app.clone(),
5493					to_app: to_app.clone(),
5494					rename_table: *rename_table,
5495					old_table_name: if *rename_table {
5496						Some(old_table_name)
5497					} else {
5498						None
5499					},
5500					new_table_name: if *rename_table {
5501						Some(new_table_name)
5502					} else {
5503						None
5504					},
5505				},
5506			);
5507		}
5508
5509		// Second-line defence against redundant single-column `AddConstraint
5510		// UNIQUE` operations. The primary fix lives in
5511		// `detect_added_constraints` (shape-match); this pass also catches
5512		// cases where the column is being added in the same migration with
5513		// `column.unique = true` *and* a peer `AddConstraint` is emitted for
5514		// it. See reinhardt-web#4448.
5515		Self::dedup_redundant_unique_add_constraints(&mut migrations_by_app);
5516
5517		// Create Migration objects for each app
5518		let mut migrations = Vec::new();
5519		for (app_label, operations) in migrations_by_app {
5520			// Placeholder name; the final migration name is generated by
5521			// MakeMigrationsCommand using MigrationNamer::generate_name().
5522			let migration_name = "autodetected".to_string();
5523
5524			let mut migration = super::Migration::new(&migration_name, &app_label);
5525			for operation in operations {
5526				migration = migration.add_operation(operation);
5527			}
5528			migrations.push(migration);
5529		}
5530
5531		migrations
5532	}
5533
5534	/// Detect newly created ManyToMany relationships
5535	///
5536	/// This method compares ManyToMany fields between from_state and to_state
5537	/// to detect new relationships that require intermediate table creation.
5538	///
5539	/// # Detection Logic
5540	/// 1. Iterate through all models in to_state
5541	/// 2. For each ManyToMany field, check if it exists in from_state
5542	/// 3. If not, mark it as a newly created ManyToMany relationship
5543	///
5544	/// # Intermediate Table Naming
5545	/// Uses Django naming convention: `{app}_{model}_{field}`
5546	/// Custom through table names are supported via `through` option.
5547	///
5548	/// # Examples
5549	///
5550	/// ```rust,ignore
5551	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, ManyToManyMetadata};
5552	///
5553	/// let from_state = ProjectState::new();
5554	/// let mut to_state = ProjectState::new();
5555	///
5556	/// // Create User model with ManyToMany to Group
5557	/// let mut user = ModelState::new("auth", "User");
5558	/// user.many_to_many_fields.push(ManyToManyMetadata {
5559	///     field_name: "groups".to_string(),
5560	///     to_model: "Group".to_string(),
5561	///     related_name: Some("users".to_string()),
5562	///     through: None,
5563	///     source_field: None,
5564	///     target_field: None,
5565	///     db_constraint_prefix: None,
5566	/// });
5567	/// to_state.add_model(user);
5568	///
5569	/// let detector = MigrationAutodetector::new(from_state, to_state);
5570	/// let changes = detector.detect_changes();
5571	///
5572	/// // Should detect created ManyToMany relationship
5573	/// assert_eq!(changes.created_many_to_many.len(), 1);
5574	/// assert_eq!(changes.created_many_to_many[0].2, "auth_user_groups");
5575	/// ```
5576	fn detect_created_many_to_many(&self, changes: &mut DetectedChanges) {
5577		for ((app_label, model_name), model_state) in &self.to_state.models {
5578			for m2m in &model_state.many_to_many_fields {
5579				// Generate the canonical through-table name from the source
5580				// model's actual `table_name`. Using `table_name` (not the
5581				// struct identifier) is required for two reasons:
5582				//   1. The ORM accessor's fallback derives the through-table
5583				//      and the source column from `S::table_name()` (see
5584				//      `crates/reinhardt-db/src/orm/many_to_many_accessor.rs`).
5585				//      The autodetector must agree on the same naming so that
5586				//      generated migrations match runtime expectations.
5587				//   2. `from_state` reconstructed from on-disk migrations only
5588				//      preserves `table_name`s (the original struct identifiers
5589				//      are lost — see #4659), so any subsequent existence check
5590				//      must use a `table_name`-derived key.
5591				// Route through `crate::m2m_naming::default_through_table`
5592				// so this existence-check key cannot drift from the
5593				// migration-emitting site (`create_intermediate_table_for_m2m`)
5594				// or the ORM accessor's fallback (#4659, #4665).
5595				let through_table = m2m.through.clone().unwrap_or_else(|| {
5596					crate::m2m_naming::default_through_table(
5597						&model_state.table_name,
5598						&m2m.field_name,
5599					)
5600				});
5601
5602				// Check whether the through-table already exists in
5603				// `from_state`. The previous implementation looked at
5604				// `from_state.get_model(app_label, model_name).many_to_many_fields`,
5605				// but the M2M metadata is not stored in on-disk
5606				// `Operation::CreateTable`, so reconstructed `from_state`
5607				// always reported `many_to_many_fields.is_empty()` and
5608				// `exists_in_from` was always `false`. As a result, the
5609				// intermediate `CreateTable` was re-emitted on every
5610				// incremental `makemigrations` run (#4659).
5611				let exists_in_from = self
5612					.from_state
5613					.find_model_by_table(&through_table)
5614					.is_some();
5615
5616				if !exists_in_from {
5617					// Add to created_many_to_many
5618					changes.created_many_to_many.push((
5619						app_label.clone(),
5620						model_name.clone(),
5621						through_table.clone(),
5622						m2m.clone(),
5623					));
5624
5625					// Add model dependencies
5626					// The intermediate table depends on both source and target models
5627					let target_app = self
5628						.find_model_app(&m2m.to_model)
5629						.unwrap_or_else(|| app_label.clone());
5630
5631					changes
5632						.model_dependencies
5633						.entry((app_label.clone(), through_table))
5634						.or_default()
5635						.extend(vec![
5636							(app_label.clone(), model_name.clone()),
5637							(target_app, m2m.to_model.clone()),
5638						]);
5639				}
5640			}
5641		}
5642	}
5643
5644	/// Find the app_label for a given model name
5645	///
5646	/// Searches through to_state models to find the app that contains the model.
5647	/// If not found in to_state, falls back to the global registry for cross-app references.
5648	fn find_model_app(&self, model_name: &str) -> Option<String> {
5649		// First, search in to_state
5650		for (app_label, name) in self.to_state.models.keys() {
5651			if name == model_name {
5652				return Some(app_label.clone());
5653			}
5654		}
5655
5656		// If not found, search in global registry for cross-app references
5657		// This is needed when generating migrations for one app that references models in another app
5658		for model_meta in super::model_registry::global_registry().get_models() {
5659			if model_meta.model_name == model_name {
5660				return Some(model_meta.app_label.clone());
5661			}
5662		}
5663
5664		None
5665	}
5666
5667	/// Detect model dependencies for proper migration ordering
5668	///
5669	/// This method analyzes ForeignKey relationships between models to ensure
5670	/// migrations are generated in the correct order. A model that references
5671	/// another model via ForeignKey depends on that model being created first.
5672	///
5673	/// # Django Reference
5674	/// Django's dependency detection is in `django/db/migrations/autodetector.py:1400`
5675	/// Function: `_generate_through_model_map` and dependency tracking
5676	///
5677	/// # Examples
5678	///
5679	/// ```rust,ignore
5680	/// use reinhardt_db::migrations::{MigrationAutodetector, ProjectState, ModelState, FieldState, FieldType};
5681	///
5682	/// let mut from_state = ProjectState::new();
5683	/// let mut to_state = ProjectState::new();
5684	///
5685	/// // Create User model
5686	/// let mut user = ModelState::new("accounts", "User");
5687	/// user.add_field(FieldState::new("id", FieldType::Integer, false));
5688	/// to_state.add_model(user);
5689	///
5690	/// // Create Post model that references User
5691	/// let mut post = ModelState::new("blog", "Post");
5692	/// post.add_field(FieldState::new("id", FieldType::Integer, false));
5693	/// post.add_field(FieldState::new("author_id", FieldType::Custom("ForeignKey(accounts.User)".into()), false));
5694	/// to_state.add_model(post);
5695	///
5696	/// let detector = MigrationAutodetector::new(from_state, to_state);
5697	/// let changes = detector.detect_changes();
5698	///
5699	/// // blog.Post depends on accounts.User
5700	/// let post_deps = changes.model_dependencies.get(&("blog".to_string(), "Post".to_string()));
5701	/// assert!(post_deps.is_some());
5702	/// assert!(post_deps.unwrap().contains(&("accounts".to_string(), "User".to_string())));
5703	/// ```
5704	fn detect_model_dependencies(&self, changes: &mut DetectedChanges) {
5705		// Analyze all models in the final state
5706		for ((app_label, model_name), model) in &self.to_state.models {
5707			let mut dependencies = Vec::new();
5708
5709			// Check each field for foreign key relationships
5710			for field in model.fields.values() {
5711				match &field.field_type {
5712					// Handle structured ForeignKey variant
5713					super::FieldType::ForeignKey { to_table, .. } => {
5714						// Find model by table name in the project state
5715						if let Some(dep) = self.find_model_by_table_name(to_table) {
5716							// Avoid self-reference unless intentional
5717							if dep != (app_label.clone(), model_name.clone()) {
5718								dependencies.push(dep);
5719							}
5720						}
5721					}
5722					// Handle structured OneToOne variant
5723					super::FieldType::OneToOne { to, .. } => {
5724						// Format: "app.Model" or "Model"
5725						if let Some(dep) = self.parse_model_reference(to, app_label)
5726							&& dep != (app_label.clone(), model_name.clone())
5727						{
5728							dependencies.push(dep);
5729						}
5730					}
5731					// Handle structured ManyToMany variant
5732					super::FieldType::ManyToMany { to, .. } => {
5733						// Format: "app.Model" or "Model"
5734						if let Some(dep) = self.parse_model_reference(to, app_label)
5735							&& dep != (app_label.clone(), model_name.clone())
5736						{
5737							dependencies.push(dep);
5738						}
5739					}
5740					// Handle legacy Custom string format
5741					super::FieldType::Custom(s) => {
5742						if let Some(referenced_model) = self.extract_related_model(s, app_label)
5743							&& referenced_model != (app_label.clone(), model_name.clone())
5744						{
5745							dependencies.push(referenced_model);
5746						}
5747					}
5748					// Skip other field types
5749					_ => {}
5750				}
5751			}
5752
5753			// Only store if there are actual dependencies
5754			if !dependencies.is_empty() {
5755				changes
5756					.model_dependencies
5757					.insert((app_label.clone(), model_name.clone()), dependencies);
5758			}
5759		}
5760	}
5761
5762	/// Extract related model from field type string
5763	///
5764	/// Parses field type strings like:
5765	/// - "ForeignKey(app.Model)" -> Some(("app", "Model"))
5766	/// - "ManyToManyField(app.Model)" -> Some(("app", "Model"))
5767	/// - "ForeignKey(Model)" -> Some((current_app, "Model"))
5768	/// - "CharField" -> None
5769	///
5770	/// # Arguments
5771	/// * `field_type` - Field type string (e.g., "ForeignKey(accounts.User)")
5772	/// * `current_app` - Current app label for resolving unqualified references
5773	///
5774	/// # Returns
5775	/// * `Some((app_label, model_name))` if field is a relation
5776	/// * `None` if field is not a relation
5777	fn extract_related_model(
5778		&self,
5779		field_type: &str,
5780		current_app: &str,
5781	) -> Option<(String, String)> {
5782		// Check for ForeignKey pattern
5783		if let Some(inner) = field_type
5784			.strip_prefix("ForeignKey(")
5785			.and_then(|s| s.strip_suffix(")"))
5786		{
5787			return self.parse_model_reference(inner, current_app);
5788		}
5789
5790		// Check for ManyToManyField pattern
5791		if let Some(inner) = field_type
5792			.strip_prefix("ManyToManyField(")
5793			.and_then(|s| s.strip_suffix(")"))
5794		{
5795			return self.parse_model_reference(inner, current_app);
5796		}
5797
5798		// Check for OneToOneField pattern
5799		if let Some(inner) = field_type
5800			.strip_prefix("OneToOneField(")
5801			.and_then(|s| s.strip_suffix(")"))
5802		{
5803			return self.parse_model_reference(inner, current_app);
5804		}
5805
5806		None
5807	}
5808
5809	/// Parse model reference string into (app_label, model_name)
5810	///
5811	/// Supports formats:
5812	/// - "app.Model" -> ("app", "Model")
5813	/// - "Model" -> (current_app, "Model") - Uses current app for unqualified references
5814	///
5815	/// # Arguments
5816	/// * `reference` - Model reference string (e.g., "accounts.User" or "User")
5817	/// * `current_app` - Current app label for resolving unqualified references
5818	///
5819	/// # Returns
5820	/// * `Some((app_label, model_name))` if parseable
5821	/// * `None` if format is invalid
5822	fn parse_model_reference(
5823		&self,
5824		reference: &str,
5825		current_app: &str,
5826	) -> Option<(String, String)> {
5827		let parts: Vec<&str> = reference.split('.').collect();
5828		match parts.as_slice() {
5829			// Fully qualified reference: "app.Model"
5830			[app, model] => Some((app.to_string(), model.to_string())),
5831			// Unqualified reference: "Model" - assume same app
5832			[model] => {
5833				// Use current app for same-app references
5834				Some((current_app.to_string(), model.to_string()))
5835			}
5836			// Invalid format
5837			_ => None,
5838		}
5839	}
5840
5841	/// Find a model in the project state by its table name
5842	///
5843	/// This method searches through all models in both from_state and to_state
5844	/// to find a model whose table name matches the given table name.
5845	///
5846	/// Table name matching supports:
5847	/// - Django-style table names: "app_modelname" (e.g., "auth_user")
5848	/// - Simple model name match: "modelname" (lowercase, e.g., "user")
5849	///
5850	/// # Arguments
5851	/// * `table_name` - The table name to search for
5852	///
5853	/// # Returns
5854	/// * `Some((app_label, model_name))` if found
5855	/// * `None` if no matching model is found
5856	fn find_model_by_table_name(&self, table_name: &str) -> Option<(String, String)> {
5857		// Search in to_state (target state has priority)
5858		for (app_label, model_name) in self.to_state.models.keys() {
5859			// Check Django-style table name: app_modelname
5860			let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
5861			if django_table == table_name {
5862				return Some((app_label.clone(), model_name.clone()));
5863			}
5864
5865			// Check simple lowercase model name
5866			if model_name.to_lowercase() == table_name {
5867				return Some((app_label.clone(), model_name.clone()));
5868			}
5869		}
5870
5871		// Fallback: search in from_state
5872		for (app_label, model_name) in self.from_state.models.keys() {
5873			let django_table = format!("{}_{}", app_label, model_name.to_lowercase());
5874			if django_table == table_name {
5875				return Some((app_label.clone(), model_name.clone()));
5876			}
5877
5878			if model_name.to_lowercase() == table_name {
5879				return Some((app_label.clone(), model_name.clone()));
5880			}
5881		}
5882
5883		None
5884	}
5885}
5886
5887impl ModelState {
5888	/// Remove a field from this model
5889	///
5890	/// # Examples
5891	///
5892	/// ```rust,ignore
5893	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
5894	///
5895	/// let mut model = ModelState::new("myapp", "User");
5896	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
5897	/// model.add_field(field);
5898	/// assert!(model.has_field("email"));
5899	///
5900	/// model.remove_field("email");
5901	/// assert!(!model.has_field("email"));
5902	/// ```
5903	pub fn remove_field(&mut self, name: &str) {
5904		self.fields.remove(name);
5905	}
5906
5907	/// Alter a field definition
5908	///
5909	/// # Examples
5910	///
5911	/// ```rust,ignore
5912	/// use reinhardt_db::migrations::{ModelState, FieldState, FieldType};
5913	///
5914	/// let mut model = ModelState::new("myapp", "User");
5915	/// let field = FieldState::new("email", FieldType::VarChar(255), false);
5916	/// model.add_field(field);
5917	///
5918	/// let new_field = FieldState::new("email", FieldType::Text, true);
5919	/// model.alter_field("email", new_field);
5920	///
5921	/// let altered = model.get_field("email").unwrap();
5922	/// assert_eq!(altered.field_type, FieldType::Text);
5923	/// assert!(altered.nullable);
5924	/// ```
5925	pub fn alter_field(&mut self, name: &str, new_field: FieldState) {
5926		self.fields.insert(name.to_string(), new_field);
5927	}
5928}
5929
5930#[cfg(test)]
5931mod tests {
5932	use super::*;
5933	use rstest::rstest;
5934
5935	/// Helper to build a ProjectState with given models
5936	fn build_project_state(models: Vec<((String, String), ModelState)>) -> ProjectState {
5937		let mut state = ProjectState::new();
5938		for (key, model) in models {
5939			state.models.insert(key, model);
5940		}
5941		state
5942	}
5943
5944	/// Helper to build a minimal ModelState
5945	fn build_model_state(
5946		app_label: &str,
5947		name: &str,
5948		fields: Vec<FieldState>,
5949		indexes: Vec<IndexDefinition>,
5950		constraints: Vec<ConstraintDefinition>,
5951	) -> ModelState {
5952		let mut field_map = std::collections::BTreeMap::new();
5953		for f in fields {
5954			field_map.insert(f.name.clone(), f);
5955		}
5956		ModelState {
5957			app_label: app_label.to_string(),
5958			name: name.to_string(),
5959			table_name: format!("{}_{}", app_label, name.to_lowercase()),
5960			fields: field_map,
5961			options: std::collections::HashMap::new(),
5962			base_model: None,
5963			inheritance_type: None,
5964			discriminator_column: None,
5965			indexes,
5966			constraints,
5967			many_to_many_fields: Vec::new(),
5968		}
5969	}
5970
5971	#[rstest]
5972	fn to_database_schema_uses_app_prefixed_table_key() {
5973		// Arrange
5974		let model = build_model_state(
5975			"blog",
5976			"Post",
5977			vec![FieldState::new(
5978				"id",
5979				super::super::FieldType::Integer,
5980				false,
5981			)],
5982			Vec::new(),
5983			Vec::new(),
5984		);
5985		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
5986
5987		// Act
5988		let schema = state.to_database_schema();
5989
5990		// Assert
5991		assert_eq!(schema.tables.len(), 1);
5992		assert!(
5993			schema.tables.contains_key("blog_post"),
5994			"table key should be app_label + '_' + lowercase model name"
5995		);
5996		let table = &schema.tables["blog_post"];
5997		assert_eq!(table.name, "blog_post");
5998	}
5999
6000	#[rstest]
6001	fn to_database_schema_prevents_cross_app_collision() {
6002		// Arrange
6003		// Two different apps with identically named models
6004		let blog_user = build_model_state(
6005			"blog",
6006			"User",
6007			vec![FieldState::new(
6008				"id",
6009				super::super::FieldType::Integer,
6010				false,
6011			)],
6012			Vec::new(),
6013			Vec::new(),
6014		);
6015		let auth_user = build_model_state(
6016			"auth",
6017			"User",
6018			vec![FieldState::new(
6019				"id",
6020				super::super::FieldType::Integer,
6021				false,
6022			)],
6023			Vec::new(),
6024			Vec::new(),
6025		);
6026		let state = build_project_state(vec![
6027			(("blog".to_string(), "User".to_string()), blog_user),
6028			(("auth".to_string(), "User".to_string()), auth_user),
6029		]);
6030
6031		// Act
6032		let schema = state.to_database_schema();
6033
6034		// Assert
6035		assert_eq!(schema.tables.len(), 2);
6036		assert!(schema.tables.contains_key("blog_user"));
6037		assert!(schema.tables.contains_key("auth_user"));
6038	}
6039
6040	#[rstest]
6041	fn to_database_schema_propagates_indexes() {
6042		// Arrange
6043		let indexes = vec![
6044			IndexDefinition {
6045				name: "idx_title".to_string(),
6046				fields: vec!["title".to_string()],
6047				unique: false,
6048			},
6049			IndexDefinition {
6050				name: "idx_slug_unique".to_string(),
6051				fields: vec!["slug".to_string()],
6052				unique: true,
6053			},
6054		];
6055		let model = build_model_state(
6056			"blog",
6057			"Post",
6058			vec![
6059				FieldState::new("title", super::super::FieldType::VarChar(255), false),
6060				FieldState::new("slug", super::super::FieldType::VarChar(100), false),
6061			],
6062			indexes,
6063			Vec::new(),
6064		);
6065		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
6066
6067		// Act
6068		let schema = state.to_database_schema();
6069
6070		// Assert
6071		let table = &schema.tables["blog_post"];
6072		assert_eq!(table.indexes.len(), 2);
6073		assert_eq!(table.indexes[0].name, "idx_title");
6074		assert_eq!(table.indexes[0].columns, vec!["title".to_string()]);
6075		assert!(!table.indexes[0].unique);
6076		assert_eq!(table.indexes[1].name, "idx_slug_unique");
6077		assert!(table.indexes[1].unique);
6078	}
6079
6080	#[rstest]
6081	fn to_database_schema_propagates_constraints() {
6082		// Arrange
6083		let constraints = vec![ConstraintDefinition {
6084			name: "uq_email".to_string(),
6085			constraint_type: "unique".to_string(),
6086			fields: vec!["email".to_string()],
6087			expression: None,
6088			foreign_key_info: None,
6089		}];
6090		let model = build_model_state(
6091			"auth",
6092			"Account",
6093			vec![FieldState::new(
6094				"email",
6095				super::super::FieldType::VarChar(255),
6096				false,
6097			)],
6098			Vec::new(),
6099			constraints,
6100		);
6101		let state = build_project_state(vec![(("auth".to_string(), "Account".to_string()), model)]);
6102
6103		// Act
6104		let schema = state.to_database_schema();
6105
6106		// Assert
6107		let table = &schema.tables["auth_account"];
6108		assert_eq!(table.constraints.len(), 1);
6109		assert_eq!(table.constraints[0].name, "uq_email");
6110		assert_eq!(table.constraints[0].constraint_type, "unique");
6111		assert_eq!(table.constraints[0].definition, "email");
6112	}
6113
6114	#[rstest]
6115	fn to_database_schema_maps_field_params() {
6116		// Arrange
6117		let mut field = FieldState::new("id", super::super::FieldType::Integer, false);
6118		field
6119			.params
6120			.insert("primary_key".to_string(), "true".to_string());
6121		field
6122			.params
6123			.insert("auto_increment".to_string(), "true".to_string());
6124		field.params.insert("default".to_string(), "0".to_string());
6125
6126		let mut nullable_field = FieldState::new("bio", super::super::FieldType::Text, true);
6127		nullable_field
6128			.params
6129			.insert("default".to_string(), "''".to_string());
6130
6131		let model = build_model_state(
6132			"users",
6133			"Profile",
6134			vec![field, nullable_field],
6135			Vec::new(),
6136			Vec::new(),
6137		);
6138		let state =
6139			build_project_state(vec![(("users".to_string(), "Profile".to_string()), model)]);
6140
6141		// Act
6142		let schema = state.to_database_schema();
6143
6144		// Assert
6145		let table = &schema.tables["users_profile"];
6146		let id_col = &table.columns["id"];
6147		assert!(id_col.primary_key);
6148		assert!(id_col.auto_increment);
6149		assert_eq!(id_col.default, Some("0".to_string()));
6150		assert!(!id_col.nullable);
6151
6152		let bio_col = &table.columns["bio"];
6153		assert!(!bio_col.primary_key);
6154		assert!(!bio_col.auto_increment);
6155		assert!(bio_col.nullable);
6156		assert_eq!(bio_col.default, Some("''".to_string()));
6157	}
6158
6159	#[rstest]
6160	fn to_database_schema_for_app_filters_by_app_label() {
6161		// Arrange
6162		let blog_post = build_model_state(
6163			"blog",
6164			"Post",
6165			vec![FieldState::new(
6166				"id",
6167				super::super::FieldType::Integer,
6168				false,
6169			)],
6170			Vec::new(),
6171			Vec::new(),
6172		);
6173		let auth_user = build_model_state(
6174			"auth",
6175			"User",
6176			vec![FieldState::new(
6177				"id",
6178				super::super::FieldType::Integer,
6179				false,
6180			)],
6181			Vec::new(),
6182			Vec::new(),
6183		);
6184		let state = build_project_state(vec![
6185			(("blog".to_string(), "Post".to_string()), blog_post),
6186			(("auth".to_string(), "User".to_string()), auth_user),
6187		]);
6188
6189		// Act
6190		let blog_schema = state.to_database_schema_for_app("blog");
6191		let auth_schema = state.to_database_schema_for_app("auth");
6192		let empty_schema = state.to_database_schema_for_app("nonexistent");
6193
6194		// Assert
6195		assert_eq!(blog_schema.tables.len(), 1);
6196		assert!(blog_schema.tables.contains_key("blog_post"));
6197
6198		assert_eq!(auth_schema.tables.len(), 1);
6199		assert!(auth_schema.tables.contains_key("auth_user"));
6200
6201		assert_eq!(empty_schema.tables.len(), 0);
6202	}
6203
6204	#[rstest]
6205	fn to_database_schema_for_app_propagates_indexes_and_constraints() {
6206		// Arrange
6207		let indexes = vec![IndexDefinition {
6208			name: "idx_created".to_string(),
6209			fields: vec!["created_at".to_string()],
6210			unique: false,
6211		}];
6212		let constraints = vec![ConstraintDefinition {
6213			name: "ck_status".to_string(),
6214			constraint_type: "check".to_string(),
6215			fields: vec!["status".to_string()],
6216			expression: Some("status IN ('draft', 'published')".to_string()),
6217			foreign_key_info: None,
6218		}];
6219		let model = build_model_state(
6220			"blog",
6221			"Post",
6222			vec![
6223				FieldState::new("created_at", super::super::FieldType::DateTime, false),
6224				FieldState::new("status", super::super::FieldType::VarChar(20), false),
6225			],
6226			indexes,
6227			constraints,
6228		);
6229		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
6230
6231		// Act
6232		let schema = state.to_database_schema_for_app("blog");
6233
6234		// Assert
6235		let table = &schema.tables["blog_post"];
6236		assert_eq!(table.indexes.len(), 1);
6237		assert_eq!(table.indexes[0].name, "idx_created");
6238		assert_eq!(table.indexes[0].columns, vec!["created_at".to_string()]);
6239
6240		assert_eq!(table.constraints.len(), 1);
6241		assert_eq!(table.constraints[0].name, "ck_status");
6242		assert_eq!(table.constraints[0].constraint_type, "check");
6243		assert_eq!(table.constraints[0].definition, "status");
6244	}
6245
6246	/// Helper to build a ModelState with a custom table name
6247	fn build_model_state_with_table_name(
6248		app_label: &str,
6249		name: &str,
6250		table_name: &str,
6251		fields: Vec<FieldState>,
6252	) -> ModelState {
6253		let mut field_map = std::collections::BTreeMap::new();
6254		for f in fields {
6255			field_map.insert(f.name.clone(), f);
6256		}
6257		ModelState {
6258			app_label: app_label.to_string(),
6259			name: name.to_string(),
6260			table_name: table_name.to_string(),
6261			fields: field_map,
6262			options: std::collections::HashMap::new(),
6263			base_model: None,
6264			inheritance_type: None,
6265			discriminator_column: None,
6266			indexes: Vec::new(),
6267			constraints: Vec::new(),
6268			many_to_many_fields: Vec::new(),
6269		}
6270	}
6271
6272	#[rstest]
6273	fn to_database_schema_respects_custom_table_name() {
6274		// Arrange
6275		let model = build_model_state_with_table_name(
6276			"blog",
6277			"Post",
6278			"custom_posts_table",
6279			vec![FieldState::new(
6280				"id",
6281				super::super::FieldType::Integer,
6282				false,
6283			)],
6284		);
6285		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
6286
6287		// Act
6288		let schema = state.to_database_schema();
6289
6290		// Assert
6291		// The HashMap key should still be the auto-generated key
6292		assert!(schema.tables.contains_key("blog_post"));
6293		// But the TableSchema.name should use the custom table name
6294		let table = &schema.tables["blog_post"];
6295		assert_eq!(table.name, "custom_posts_table");
6296	}
6297
6298	#[rstest]
6299	fn to_database_schema_for_app_respects_custom_table_name() {
6300		// Arrange
6301		let model = build_model_state_with_table_name(
6302			"blog",
6303			"Post",
6304			"custom_posts_table",
6305			vec![FieldState::new(
6306				"id",
6307				super::super::FieldType::Integer,
6308				false,
6309			)],
6310		);
6311		let state = build_project_state(vec![(("blog".to_string(), "Post".to_string()), model)]);
6312
6313		// Act
6314		let schema = state.to_database_schema_for_app("blog");
6315
6316		// Assert
6317		assert!(schema.tables.contains_key("blog_post"));
6318		let table = &schema.tables["blog_post"];
6319		assert_eq!(table.name, "custom_posts_table");
6320	}
6321
6322	// --- Tests for #3204: no-op migration detection ---
6323
6324	/// Build fields commonly used in #3204 tests
6325	fn sample_fields() -> Vec<FieldState> {
6326		vec![
6327			FieldState::new("id", super::super::FieldType::Integer, false),
6328			FieldState::new("name", super::super::FieldType::VarChar(255), false),
6329		]
6330	}
6331
6332	/// Regression test for issue #4659.
6333	///
6334	/// Scenario: a model struct is renamed (`Room` -> `DMRoom`) but the
6335	/// `table_name` stays the same (`dm_room`), and an M2M through-table
6336	/// (`dm_room_members`) already exists on disk. The state reconstructed
6337	/// from on-disk migrations loses the original struct identifier (it
6338	/// derives a `RoomMembers`-style approximation via
6339	/// `table_name_to_model_name`) and the M2M metadata is gone — only the
6340	/// raw through-table survives.
6341	///
6342	/// Before the fix, `detect_created_many_to_many` keyed its existence
6343	/// check on `(app_label, model_name)` and inspected
6344	/// `many_to_many_fields`, which is always empty on reconstructed
6345	/// states, so the through-table was spuriously re-created on every
6346	/// incremental `makemigrations` run.
6347	#[rstest]
6348	fn detect_created_many_to_many_recognises_existing_through_table_by_table_name() {
6349		use super::super::model_registry::ManyToManyMetadata;
6350
6351		// Arrange: from_state mimics the on-disk reconstruction.
6352		// `table_name_to_model_name` produces `Room` / `RoomMembers` for
6353		// tables `dm_room` / `dm_room_members`. The through table appears
6354		// as an ordinary model with no M2M metadata attached.
6355		let from_room = build_model_state_with_table_name("dm", "Room", "dm_room", sample_fields());
6356		let from_through = build_model_state_with_table_name(
6357			"dm",
6358			"RoomMembers",
6359			"dm_room_members",
6360			sample_fields(),
6361		);
6362		let from_state = build_project_state(vec![
6363			(("dm".to_string(), "Room".to_string()), from_room),
6364			(("dm".to_string(), "RoomMembers".to_string()), from_through),
6365		]);
6366
6367		// to_state: the renamed struct (`DMRoom`) re-declares the same
6368		// M2M field. The synthetic through-table model (`DMRoomMembers`)
6369		// the macro emits keeps the canonical `dm_room_members` table
6370		// name.
6371		let mut to_room =
6372			build_model_state_with_table_name("dm", "DMRoom", "dm_room", sample_fields());
6373		to_room
6374			.many_to_many_fields
6375			.push(ManyToManyMetadata::new("members", "User"));
6376		let to_through = build_model_state_with_table_name(
6377			"dm",
6378			"DMRoomMembers",
6379			"dm_room_members",
6380			sample_fields(),
6381		);
6382		let to_state = build_project_state(vec![
6383			(("dm".to_string(), "DMRoom".to_string()), to_room),
6384			(("dm".to_string(), "DMRoomMembers".to_string()), to_through),
6385		]);
6386
6387		let detector = MigrationAutodetector::new(from_state, to_state);
6388
6389		// Act
6390		let changes = detector.detect_changes();
6391
6392		// Assert: the through table is already present in from_state, so
6393		// the autodetector must not re-create it (#4659).
6394		assert!(
6395			changes.created_many_to_many.is_empty(),
6396			"M2M through table already exists in from_state; expected no \
6397			 created_many_to_many, got {:?}",
6398			changes.created_many_to_many
6399		);
6400	}
6401
6402	#[rstest]
6403	fn detect_renamed_models_skips_struct_only_rename_with_same_table_name() {
6404		// Arrange: struct name changed (Clusters -> Cluster) but table name is the same
6405		let from_model =
6406			build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
6407		let to_model =
6408			build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
6409
6410		let from_state = build_project_state(vec![(
6411			("myapp".to_string(), "Clusters".to_string()),
6412			from_model,
6413		)]);
6414		let to_state = build_project_state(vec![(
6415			("myapp".to_string(), "Cluster".to_string()),
6416			to_model,
6417		)]);
6418
6419		let detector = MigrationAutodetector::new(from_state, to_state);
6420
6421		// Act
6422		let changes = detector.detect_changes();
6423
6424		// Assert: no rename should be detected
6425		assert!(
6426			changes.renamed_models.is_empty(),
6427			"struct-only rename with same table name should not produce renamed_models"
6428		);
6429	}
6430
6431	#[rstest]
6432	fn detect_renamed_models_detects_actual_table_rename() {
6433		// Arrange: struct name changed AND table name changed
6434		let from_model =
6435			build_model_state_with_table_name("myapp", "OldModel", "old_table", sample_fields());
6436		let to_model =
6437			build_model_state_with_table_name("myapp", "NewModel", "new_table", sample_fields());
6438
6439		let from_state = build_project_state(vec![(
6440			("myapp".to_string(), "OldModel".to_string()),
6441			from_model,
6442		)]);
6443		let to_state = build_project_state(vec![(
6444			("myapp".to_string(), "NewModel".to_string()),
6445			to_model,
6446		)]);
6447
6448		let detector = MigrationAutodetector::new(from_state, to_state);
6449
6450		// Act
6451		let changes = detector.detect_changes();
6452
6453		// Assert: rename should be detected
6454		assert_eq!(
6455			changes.renamed_models.len(),
6456			1,
6457			"actual table rename should be detected"
6458		);
6459		assert_eq!(changes.renamed_models[0].1, "OldModel");
6460		assert_eq!(changes.renamed_models[0].2, "NewModel");
6461	}
6462
6463	#[rstest]
6464	fn has_field_changed_ignores_non_schema_params() {
6465		// Arrange: same schema, but to_field has extra non-schema params
6466		let from_field = FieldState {
6467			name: "email".to_string(),
6468			field_type: super::super::FieldType::VarChar(255),
6469			nullable: false,
6470			params: std::collections::HashMap::new(),
6471			foreign_key: None,
6472		};
6473		let mut to_params = std::collections::HashMap::new();
6474		to_params.insert("max_length".to_string(), "255".to_string());
6475		to_params.insert("null".to_string(), "false".to_string());
6476		to_params.insert("blank".to_string(), "false".to_string());
6477		let to_field = FieldState {
6478			name: "email".to_string(),
6479			field_type: super::super::FieldType::VarChar(255),
6480			nullable: false,
6481			params: to_params,
6482			foreign_key: None,
6483		};
6484
6485		let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
6486
6487		// Act
6488		let changed = detector.has_field_changed("email", &from_field, &to_field);
6489
6490		// Assert: should NOT be detected as changed
6491		assert!(
6492			!changed,
6493			"fields with identical schema but different non-schema params should not be detected as changed"
6494		);
6495	}
6496
6497	#[rstest]
6498	fn generate_operations_empty_for_struct_only_rename() {
6499		// Arrange: struct name changed but table name and fields are the same
6500		let from_model =
6501			build_model_state_with_table_name("myapp", "Clusters", "clusters", sample_fields());
6502		let to_model =
6503			build_model_state_with_table_name("myapp", "Cluster", "clusters", sample_fields());
6504
6505		let from_state = build_project_state(vec![(
6506			("myapp".to_string(), "Clusters".to_string()),
6507			from_model,
6508		)]);
6509		let to_state = build_project_state(vec![(
6510			("myapp".to_string(), "Cluster".to_string()),
6511			to_model,
6512		)]);
6513
6514		let detector = MigrationAutodetector::new(from_state, to_state);
6515
6516		// Act
6517		let operations = detector.generate_operations();
6518
6519		// Assert: no operations should be generated
6520		assert!(
6521			operations.is_empty(),
6522			"struct-only rename with same table name and identical fields should produce no operations, got: {:?}",
6523			operations
6524		);
6525	}
6526
6527	#[rstest]
6528	fn detect_composite_pk_added_emits_create_composite_primary_key() {
6529		// Arrange
6530		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
6531		let tenant_id_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
6532
6533		let from_model = build_model_state(
6534			"billing",
6535			"Invoice",
6536			vec![id_field.clone(), tenant_id_field.clone()],
6537			Vec::new(),
6538			Vec::new(),
6539		);
6540		let composite_pk = ConstraintDefinition {
6541			name: "billing_invoice_pkey".to_string(),
6542			constraint_type: "primary_key".to_string(),
6543			fields: vec!["id".to_string(), "tenant_id".to_string()],
6544			expression: None,
6545			foreign_key_info: None,
6546		};
6547		let to_model = build_model_state(
6548			"billing",
6549			"Invoice",
6550			vec![id_field, tenant_id_field],
6551			Vec::new(),
6552			vec![composite_pk],
6553		);
6554
6555		let from_state = build_project_state(vec![(
6556			("billing".to_string(), "Invoice".to_string()),
6557			from_model,
6558		)]);
6559		let to_state = build_project_state(vec![(
6560			("billing".to_string(), "Invoice".to_string()),
6561			to_model,
6562		)]);
6563		let detector = MigrationAutodetector::new(from_state, to_state);
6564
6565		// Act
6566		let operations = detector.generate_operations();
6567
6568		// Assert
6569		assert_eq!(operations.len(), 1);
6570		assert!(
6571			matches!(
6572				&operations[0],
6573				super::super::Operation::CreateCompositePrimaryKey {
6574					table,
6575					columns,
6576					..
6577				} if table == "billing_invoice"
6578					&& columns == &["id".to_string(), "tenant_id".to_string()]
6579			),
6580			"expected CreateCompositePrimaryKey, got: {:?}",
6581			operations
6582		);
6583	}
6584
6585	#[rstest]
6586	fn detect_composite_pk_unchanged_emits_no_operations() {
6587		// Arrange — same composite PK in both states should produce no operations
6588		let composite_pk = ConstraintDefinition {
6589			name: "billing_invoice_pkey".to_string(),
6590			constraint_type: "primary_key".to_string(),
6591			fields: vec!["id".to_string(), "tenant_id".to_string()],
6592			expression: None,
6593			foreign_key_info: None,
6594		};
6595		let from_model = build_model_state(
6596			"billing",
6597			"Invoice",
6598			vec![
6599				FieldState::new("id", super::super::FieldType::Integer, false),
6600				FieldState::new("tenant_id", super::super::FieldType::Integer, false),
6601			],
6602			Vec::new(),
6603			vec![composite_pk.clone()],
6604		);
6605		let to_model = build_model_state(
6606			"billing",
6607			"Invoice",
6608			vec![
6609				FieldState::new("id", super::super::FieldType::Integer, false),
6610				FieldState::new("tenant_id", super::super::FieldType::Integer, false),
6611			],
6612			Vec::new(),
6613			vec![composite_pk],
6614		);
6615
6616		let from_state = build_project_state(vec![(
6617			("billing".to_string(), "Invoice".to_string()),
6618			from_model,
6619		)]);
6620		let to_state = build_project_state(vec![(
6621			("billing".to_string(), "Invoice".to_string()),
6622			to_model,
6623		)]);
6624		let detector = MigrationAutodetector::new(from_state, to_state);
6625
6626		// Act
6627		let operations = detector.generate_operations();
6628
6629		// Assert
6630		assert!(
6631			operations.is_empty(),
6632			"unchanged composite PK should produce no operations, got: {:?}",
6633			operations
6634		);
6635	}
6636
6637	#[rstest]
6638	fn detect_composite_pk_changed_fields_emits_drop_and_create() {
6639		// Arrange — same constraint name but different field set
6640		let composite_pk_from = ConstraintDefinition {
6641			name: "billing_invoice_pkey".to_string(),
6642			constraint_type: "primary_key".to_string(),
6643			fields: vec!["id".to_string(), "tenant_id".to_string()],
6644			expression: None,
6645			foreign_key_info: None,
6646		};
6647		let composite_pk_to = ConstraintDefinition {
6648			name: "billing_invoice_pkey".to_string(),
6649			constraint_type: "primary_key".to_string(),
6650			fields: vec!["id".to_string(), "org_id".to_string()],
6651			expression: None,
6652			foreign_key_info: None,
6653		};
6654		let from_model = build_model_state(
6655			"billing",
6656			"Invoice",
6657			vec![
6658				FieldState::new("id", super::super::FieldType::Integer, false),
6659				FieldState::new("tenant_id", super::super::FieldType::Integer, false),
6660			],
6661			Vec::new(),
6662			vec![composite_pk_from],
6663		);
6664		let to_model = build_model_state(
6665			"billing",
6666			"Invoice",
6667			vec![
6668				FieldState::new("id", super::super::FieldType::Integer, false),
6669				FieldState::new("org_id", super::super::FieldType::Integer, false),
6670			],
6671			Vec::new(),
6672			vec![composite_pk_to],
6673		);
6674		let from_state = build_project_state(vec![(
6675			("billing".to_string(), "Invoice".to_string()),
6676			from_model,
6677		)]);
6678		let to_state = build_project_state(vec![(
6679			("billing".to_string(), "Invoice".to_string()),
6680			to_model,
6681		)]);
6682		let detector = MigrationAutodetector::new(from_state, to_state);
6683
6684		// Act
6685		let operations = detector.generate_operations();
6686
6687		// Assert — expect DropConstraint followed by CreateCompositePrimaryKey
6688		let drop_op = operations.iter().find(|op| {
6689			matches!(op, super::super::Operation::DropConstraint { constraint_name, .. }
6690				if constraint_name == "billing_invoice_pkey")
6691		});
6692		let create_op = operations.iter().find(|op| {
6693			matches!(op, super::super::Operation::CreateCompositePrimaryKey { columns, .. }
6694				if columns == &["id".to_string(), "org_id".to_string()])
6695		});
6696		assert!(
6697			drop_op.is_some(),
6698			"expected DropConstraint for modified composite PK, got: {:?}",
6699			operations
6700		);
6701		assert!(
6702			create_op.is_some(),
6703			"expected CreateCompositePrimaryKey with new fields, got: {:?}",
6704			operations
6705		);
6706	}
6707
6708	#[rstest]
6709	fn detect_sequence_reset_emits_set_auto_increment_value() {
6710		// Arrange
6711		let mut id_field = FieldState::new("id", super::super::FieldType::BigInteger, false);
6712		id_field
6713			.params
6714			.insert("auto_increment".to_string(), "true".to_string());
6715
6716		let from_model = build_model_state(
6717			"shop",
6718			"Order",
6719			vec![id_field.clone()],
6720			Vec::new(),
6721			Vec::new(),
6722		);
6723		let mut to_model =
6724			build_model_state("shop", "Order", vec![id_field], Vec::new(), Vec::new());
6725		to_model
6726			.options
6727			.insert("sequence_reset".to_string(), "1000".to_string());
6728
6729		let from_state = build_project_state(vec![(
6730			("shop".to_string(), "Order".to_string()),
6731			from_model,
6732		)]);
6733		let to_state =
6734			build_project_state(vec![(("shop".to_string(), "Order".to_string()), to_model)]);
6735		let detector = MigrationAutodetector::new(from_state, to_state);
6736
6737		// Act
6738		let operations = detector.generate_operations();
6739
6740		// Assert
6741		assert_eq!(operations.len(), 1);
6742		assert!(
6743			matches!(
6744				&operations[0],
6745				super::super::Operation::SetAutoIncrementValue {
6746					table,
6747					column,
6748					value,
6749				} if table == "shop_order" && column == "id" && *value == 1000
6750			),
6751			"expected SetAutoIncrementValue, got: {:?}",
6752			operations
6753		);
6754	}
6755
6756	#[rstest]
6757	fn detect_added_unique_together_emits_add_constraint() {
6758		// Arrange — same model in both states, but to_state adds a UNIQUE
6759		// constraint over (organization_id, name). This mirrors the
6760		// `unique_together = ("organization_id", "name")` macro form.
6761		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
6762		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
6763		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
6764
6765		let from_model = build_model_state(
6766			"clusters",
6767			"Cluster",
6768			vec![id_field.clone(), org_field.clone(), name_field.clone()],
6769			Vec::new(),
6770			Vec::new(),
6771		);
6772		let unique_constraint = ConstraintDefinition {
6773			name: "clusters_cluster_organization_id_name_uniq".to_string(),
6774			constraint_type: "unique".to_string(),
6775			fields: vec!["organization_id".to_string(), "name".to_string()],
6776			expression: None,
6777			foreign_key_info: None,
6778		};
6779		let to_model = build_model_state(
6780			"clusters",
6781			"Cluster",
6782			vec![id_field, org_field, name_field],
6783			Vec::new(),
6784			vec![unique_constraint],
6785		);
6786
6787		let from_state = build_project_state(vec![(
6788			("clusters".to_string(), "Cluster".to_string()),
6789			from_model,
6790		)]);
6791		let to_state = build_project_state(vec![(
6792			("clusters".to_string(), "Cluster".to_string()),
6793			to_model,
6794		)]);
6795		let detector = MigrationAutodetector::new(from_state, to_state);
6796
6797		// Act
6798		let operations = detector.generate_operations();
6799
6800		// Assert — exactly one AddConstraint targeting the cluster table
6801		// with SQL referencing both columns of the composite UNIQUE.
6802		assert_eq!(
6803			operations.len(),
6804			1,
6805			"expected exactly one AddConstraint operation, got: {:?}",
6806			operations
6807		);
6808		let super::super::Operation::AddConstraint {
6809			table,
6810			constraint_sql,
6811		} = &operations[0]
6812		else {
6813			panic!(
6814				"expected Operation::AddConstraint, got: {:?}",
6815				operations[0]
6816			);
6817		};
6818		assert_eq!(table, "clusters_cluster");
6819		assert!(
6820			constraint_sql.contains("UNIQUE"),
6821			"constraint SQL should declare UNIQUE, got: {}",
6822			constraint_sql
6823		);
6824		assert!(
6825			constraint_sql.contains("organization_id"),
6826			"constraint SQL should reference organization_id, got: {}",
6827			constraint_sql
6828		);
6829		assert!(
6830			constraint_sql.contains("name"),
6831			"constraint SQL should reference name, got: {}",
6832			constraint_sql
6833		);
6834		assert!(
6835			constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
6836			"constraint SQL should carry the constraint name, got: {}",
6837			constraint_sql
6838		);
6839	}
6840
6841	#[rstest]
6842	fn detect_removed_unique_together_emits_drop_constraint() {
6843		// Arrange — symmetric reverse: from_state has the UNIQUE, to_state
6844		// drops it. The autodetector must emit a DropConstraint so the DB
6845		// is brought back in sync.
6846		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
6847		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
6848		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
6849
6850		let unique_constraint = ConstraintDefinition {
6851			name: "clusters_cluster_organization_id_name_uniq".to_string(),
6852			constraint_type: "unique".to_string(),
6853			fields: vec!["organization_id".to_string(), "name".to_string()],
6854			expression: None,
6855			foreign_key_info: None,
6856		};
6857		let from_model = build_model_state(
6858			"clusters",
6859			"Cluster",
6860			vec![id_field.clone(), org_field.clone(), name_field.clone()],
6861			Vec::new(),
6862			vec![unique_constraint],
6863		);
6864		let to_model = build_model_state(
6865			"clusters",
6866			"Cluster",
6867			vec![id_field, org_field, name_field],
6868			Vec::new(),
6869			Vec::new(),
6870		);
6871
6872		let from_state = build_project_state(vec![(
6873			("clusters".to_string(), "Cluster".to_string()),
6874			from_model,
6875		)]);
6876		let to_state = build_project_state(vec![(
6877			("clusters".to_string(), "Cluster".to_string()),
6878			to_model,
6879		)]);
6880		let detector = MigrationAutodetector::new(from_state, to_state);
6881
6882		// Act
6883		let operations = detector.generate_operations();
6884
6885		// Assert
6886		assert_eq!(
6887			operations.len(),
6888			1,
6889			"expected exactly one DropConstraint operation, got: {:?}",
6890			operations
6891		);
6892		let super::super::Operation::DropConstraint {
6893			table,
6894			constraint_name,
6895		} = &operations[0]
6896		else {
6897			panic!(
6898				"expected Operation::DropConstraint, got: {:?}",
6899				operations[0]
6900			);
6901		};
6902		assert_eq!(table, "clusters_cluster");
6903		assert_eq!(
6904			constraint_name,
6905			"clusters_cluster_organization_id_name_uniq"
6906		);
6907	}
6908
6909	#[rstest]
6910	fn detect_added_unique_together_via_offline_reconstructed_from_state() {
6911		// Arrange — regression for issue #4032.
6912		//
6913		// When `makemigrations` falls back to file-based state reconstruction
6914		// (no DB available), `from_state` is rebuilt from migration
6915		// `Operation::CreateTable` entries and keyed by the PascalCase form
6916		// of the table name (e.g. table `"clusters"` -> key `"Clusters"`),
6917		// while `to_state` is keyed by the registered struct name
6918		// (e.g. `"Cluster"`). Both share the same `table_name`.
6919		//
6920		// Constraint diffing must locate the corresponding model by
6921		// `table_name` rather than by struct-name key, otherwise added
6922		// `unique_together` constraints are silently dropped.
6923		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
6924		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
6925		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
6926
6927		// from_state: keyed by table-derived name "Clusters", no constraints.
6928		let mut from_model = build_model_state(
6929			"clusters",
6930			"Clusters",
6931			vec![id_field.clone(), org_field.clone(), name_field.clone()],
6932			Vec::new(),
6933			Vec::new(),
6934		);
6935		from_model.table_name = "clusters_cluster".to_string();
6936
6937		// to_state: keyed by struct name "Cluster", carries the unique constraint.
6938		let unique_constraint = ConstraintDefinition {
6939			name: "clusters_cluster_organization_id_name_uniq".to_string(),
6940			constraint_type: "unique".to_string(),
6941			fields: vec!["organization_id".to_string(), "name".to_string()],
6942			expression: None,
6943			foreign_key_info: None,
6944		};
6945		let to_model = build_model_state(
6946			"clusters",
6947			"Cluster",
6948			vec![id_field, org_field, name_field],
6949			Vec::new(),
6950			vec![unique_constraint],
6951		);
6952
6953		let from_state = build_project_state(vec![(
6954			("clusters".to_string(), "Clusters".to_string()),
6955			from_model,
6956		)]);
6957		let to_state = build_project_state(vec![(
6958			("clusters".to_string(), "Cluster".to_string()),
6959			to_model,
6960		)]);
6961		let detector = MigrationAutodetector::new(from_state, to_state);
6962
6963		// Act
6964		let operations = detector.generate_operations();
6965
6966		// Assert — exactly one AddConstraint, targeted at the shared table.
6967		// No spurious operations (in particular no AlterColumn/RenameModel)
6968		// must leak through from the model-name mismatch.
6969		assert_eq!(
6970			operations.len(),
6971			1,
6972			"expected exactly one AddConstraint operation, got: {:?}",
6973			operations
6974		);
6975		let super::super::Operation::AddConstraint {
6976			table,
6977			constraint_sql,
6978		} = &operations[0]
6979		else {
6980			panic!(
6981				"expected Operation::AddConstraint, got: {:?}",
6982				operations[0]
6983			);
6984		};
6985		assert_eq!(table, "clusters_cluster");
6986		assert!(
6987			constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
6988			"constraint SQL should carry the constraint name, got: {}",
6989			constraint_sql
6990		);
6991	}
6992
6993	#[rstest]
6994	fn detect_removed_unique_together_via_offline_reconstructed_from_state() {
6995		// Arrange — symmetric regression for issue #4032 covering the
6996		// removal direction: offline-reconstructed `from_state` retains a
6997		// `unique_together` constraint, and the registered model in
6998		// `to_state` no longer declares it. The diff must emit a
6999		// `DropConstraint` despite the model-name key mismatch.
7000		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7001		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7002		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7003
7004		let unique_constraint = ConstraintDefinition {
7005			name: "clusters_cluster_organization_id_name_uniq".to_string(),
7006			constraint_type: "unique".to_string(),
7007			fields: vec!["organization_id".to_string(), "name".to_string()],
7008			expression: None,
7009			foreign_key_info: None,
7010		};
7011		let mut from_model = build_model_state(
7012			"clusters",
7013			"Clusters",
7014			vec![id_field.clone(), org_field.clone(), name_field.clone()],
7015			Vec::new(),
7016			vec![unique_constraint],
7017		);
7018		from_model.table_name = "clusters_cluster".to_string();
7019
7020		let to_model = build_model_state(
7021			"clusters",
7022			"Cluster",
7023			vec![id_field, org_field, name_field],
7024			Vec::new(),
7025			Vec::new(),
7026		);
7027
7028		let from_state = build_project_state(vec![(
7029			("clusters".to_string(), "Clusters".to_string()),
7030			from_model,
7031		)]);
7032		let to_state = build_project_state(vec![(
7033			("clusters".to_string(), "Cluster".to_string()),
7034			to_model,
7035		)]);
7036		let detector = MigrationAutodetector::new(from_state, to_state);
7037
7038		// Act
7039		let operations = detector.generate_operations();
7040
7041		// Assert
7042		assert_eq!(
7043			operations.len(),
7044			1,
7045			"expected exactly one DropConstraint operation, got: {:?}",
7046			operations
7047		);
7048		let super::super::Operation::DropConstraint {
7049			table,
7050			constraint_name,
7051		} = &operations[0]
7052		else {
7053			panic!(
7054				"expected Operation::DropConstraint, got: {:?}",
7055				operations[0]
7056			);
7057		};
7058		assert_eq!(table, "clusters_cluster");
7059		assert_eq!(
7060			constraint_name,
7061			"clusters_cluster_organization_id_name_uniq"
7062		);
7063	}
7064
7065	#[rstest]
7066	fn has_field_changed_ignores_param_population_skew() {
7067		// Arrange — regression for issue #4049.
7068		//
7069		// `from_state` is rebuilt from migration files via
7070		// `column_def_to_field_state`, which only inserts schema-affecting
7071		// params (`primary_key`, `auto_increment`, `unique`, `default`) when
7072		// their value is true/Some. `to_state` is rebuilt from the macro
7073		// registry, which inserts explicit "true"/"false" strings for
7074		// `not_null`, `null`, etc. on every field.
7075		//
7076		// The two `FieldState` HashMaps are therefore asymmetric even when
7077		// the underlying schema is identical, and `has_field_changed` must
7078		// not treat that asymmetry as a real change.
7079		let mut from_params = std::collections::HashMap::new();
7080		from_params.insert("primary_key".to_string(), "true".to_string());
7081		from_params.insert("auto_increment".to_string(), "true".to_string());
7082		let from_field = FieldState {
7083			name: "id".to_string(),
7084			field_type: super::super::FieldType::BigInteger,
7085			nullable: false,
7086			params: from_params,
7087			foreign_key: None,
7088		};
7089
7090		// to_field carries the macro-registry-style asymmetric params. In
7091		// particular, schema-affecting keys like `unique` and `default` may be
7092		// populated explicitly with their false/empty value on the to side
7093		// while the migration-replay from side simply omits the key. The raw
7094		// HashMap comparison previously surfaced this as a difference; the
7095		// canonical ColumnDefinition collapses None and "false" to the same
7096		// `false`, so the field is correctly seen as unchanged.
7097		let mut to_params = std::collections::HashMap::new();
7098		to_params.insert("primary_key".to_string(), "true".to_string());
7099		to_params.insert("auto_increment".to_string(), "true".to_string());
7100		to_params.insert("not_null".to_string(), "true".to_string());
7101		to_params.insert("null".to_string(), "false".to_string());
7102		to_params.insert("unique".to_string(), "false".to_string());
7103		let to_field = FieldState {
7104			name: "id".to_string(),
7105			field_type: super::super::FieldType::BigInteger,
7106			nullable: false,
7107			params: to_params,
7108			foreign_key: None,
7109		};
7110
7111		let detector = MigrationAutodetector::new(ProjectState::new(), ProjectState::new());
7112
7113		// Act
7114		let changed = detector.has_field_changed("id", &from_field, &to_field);
7115
7116		// Assert — schema is identical (BigInteger PK NOT NULL auto_increment,
7117		// not unique). Asymmetric param maps must not surface as a change.
7118		assert!(
7119			!changed,
7120			"identical schema with asymmetric param populations between migration replay and macro registry must not be detected as changed"
7121		);
7122	}
7123
7124	#[rstest]
7125	fn generate_operations_no_spurious_altercolumn_for_pk_via_offline_reconstructed_state() {
7126		// Arrange — regression for issue #4049.
7127		//
7128		// When `makemigrations` runs offline (no live DB), `from_state` is
7129		// reconstructed from migration files and keyed by the PascalCase form
7130		// of the table name (e.g. table `"clusters"` -> key `"Clusters"`),
7131		// while `to_state` is keyed by the registered struct name (`"Cluster"`).
7132		// On top of that, the two states populate `FieldState.params`
7133		// asymmetrically: migration replay only inserts schema-affecting params
7134		// when their value is true/Some, whereas the macro registry inserts
7135		// explicit "true"/"false" strings for `not_null`, `null`, etc.
7136		//
7137		// The diff must NOT emit a no-op `Operation::AlterColumn` for the
7138		// unchanged `id` primary key, even though the params HashMap differs.
7139		let mut from_id_params = std::collections::HashMap::new();
7140		from_id_params.insert("primary_key".to_string(), "true".to_string());
7141		from_id_params.insert("auto_increment".to_string(), "true".to_string());
7142		let from_id_field = FieldState {
7143			name: "id".to_string(),
7144			field_type: super::super::FieldType::BigInteger,
7145			nullable: false,
7146			params: from_id_params,
7147			foreign_key: None,
7148		};
7149		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7150		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7151
7152		// from_state: keyed by table-derived name "Clusters", no constraints,
7153		// migration-replay-style sparse params on the PK column.
7154		let mut from_model = build_model_state(
7155			"clusters",
7156			"Clusters",
7157			vec![from_id_field, org_field.clone(), name_field.clone()],
7158			Vec::new(),
7159			Vec::new(),
7160		);
7161		from_model.table_name = "clusters_cluster".to_string();
7162
7163		// to_state: keyed by struct name "Cluster", carries an added
7164		// unique_together constraint, macro-registry-style dense params on the
7165		// PK column (`not_null`, `null`, `unique` explicitly populated even
7166		// when their value is the default false). The from side omits these
7167		// keys entirely, which previously surfaced as a fictitious
7168		// difference in `has_field_changed`'s raw HashMap comparison.
7169		let mut to_id_params = std::collections::HashMap::new();
7170		to_id_params.insert("primary_key".to_string(), "true".to_string());
7171		to_id_params.insert("auto_increment".to_string(), "true".to_string());
7172		to_id_params.insert("not_null".to_string(), "true".to_string());
7173		to_id_params.insert("null".to_string(), "false".to_string());
7174		to_id_params.insert("unique".to_string(), "false".to_string());
7175		let to_id_field = FieldState {
7176			name: "id".to_string(),
7177			field_type: super::super::FieldType::BigInteger,
7178			nullable: false,
7179			params: to_id_params,
7180			foreign_key: None,
7181		};
7182		let unique_constraint = ConstraintDefinition {
7183			name: "clusters_cluster_organization_id_name_uniq".to_string(),
7184			constraint_type: "unique".to_string(),
7185			fields: vec!["organization_id".to_string(), "name".to_string()],
7186			expression: None,
7187			foreign_key_info: None,
7188		};
7189		let to_model = build_model_state(
7190			"clusters",
7191			"Cluster",
7192			vec![to_id_field, org_field, name_field],
7193			Vec::new(),
7194			vec![unique_constraint],
7195		);
7196
7197		let from_state = build_project_state(vec![(
7198			("clusters".to_string(), "Clusters".to_string()),
7199			from_model,
7200		)]);
7201		let to_state = build_project_state(vec![(
7202			("clusters".to_string(), "Cluster".to_string()),
7203			to_model,
7204		)]);
7205		let detector = MigrationAutodetector::new(from_state, to_state);
7206
7207		// Act
7208		let operations = detector.generate_operations();
7209
7210		// Assert — exactly one AddConstraint and no spurious AlterColumn for
7211		// the unchanged PK. The asymmetric param populations must collapse to
7212		// the same canonical `ColumnDefinition` and never surface as a diff.
7213		assert!(
7214			!operations
7215				.iter()
7216				.any(|op| matches!(op, super::super::Operation::AlterColumn { .. })),
7217			"no AlterColumn must be emitted for unchanged PK under offline state reconstruction, got: {:?}",
7218			operations
7219		);
7220		assert_eq!(
7221			operations.len(),
7222			1,
7223			"expected exactly one AddConstraint operation, got: {:?}",
7224			operations
7225		);
7226		assert!(
7227			matches!(
7228				&operations[0],
7229				super::super::Operation::AddConstraint { .. }
7230			),
7231			"expected the single operation to be AddConstraint, got: {:?}",
7232			operations[0]
7233		);
7234	}
7235
7236	#[rstest]
7237	fn generate_operations_no_spurious_altercolumn_for_option_pk_via_apply_migration_operations() {
7238		// Arrange — regression for issue #4052 (residual after #4050).
7239		//
7240		// Reproduces the production CLI path that #4050's regression test
7241		// missed: from_state is built by feeding a synthetic
7242		// `Operation::CreateTable` (modeled on `0001_initial.rs`) through
7243		// `ProjectState::apply_migration_operations`, so its `id` FieldState
7244		// flows through `column_def_to_field_state`. to_state mirrors the
7245		// `#[model]` macro's output for `id: Option<i64>` with
7246		// `#[field(primary_key = true)]` AFTER the macro fix that suppresses
7247		// `null = "true"` for primary keys (the Option<T> wrapper for PKs
7248		// reflects "id is None until DB assigns it on insert", not DB-level
7249		// nullability).
7250		//
7251		// Pre-fix, the macro emitted `null = "true"` for any Option<T>
7252		// field, including PKs. `to_model_state` then set
7253		// `FieldState.nullable = true` while `column_def_to_field_state`
7254		// produced `nullable = false`. `has_field_changed`'s direct
7255		// `nullable != nullable` short-circuit (added by #4050 to keep the
7256		// authoritative NOT NULL bit on the canonical comparison path)
7257		// returned true before the canonical `ColumnDefinition::from_field_state`
7258		// folding could absorb the asymmetry, surfacing as a no-op
7259		// `Operation::AlterColumn { old_definition: None, .. }` for the
7260		// unchanged PK.
7261
7262		// Build to_state via the model registry layer that the macro feeds.
7263		// Mirror the FIXED macro params for `id: Option<i64>` with
7264		// `#[field(primary_key = true)]`: `null = "false"` (forced by the
7265		// fix), `not_null = "true"`, `primary_key = "true"`,
7266		// `auto_increment = "true"`. The dense param population is exactly
7267		// what `ModelMetadata::to_model_state` consumes.
7268		let mut id_meta =
7269			super::super::model_registry::FieldMetadata::new(super::super::FieldType::BigInteger);
7270		id_meta = id_meta
7271			.with_param("primary_key", "true")
7272			.with_param("auto_increment", "true")
7273			.with_param("not_null", "true")
7274			.with_param("null", "false");
7275		let mut name_meta =
7276			super::super::model_registry::FieldMetadata::new(super::super::FieldType::VarChar(255));
7277		name_meta = name_meta
7278			.with_param("max_length", "255")
7279			.with_param("not_null", "true")
7280			.with_param("null", "false");
7281
7282		let mut metadata =
7283			super::super::model_registry::ModelMetadata::new("clusters", "Cluster", "clusters");
7284		metadata.add_field("id".to_string(), id_meta);
7285		metadata.add_field("name".to_string(), name_meta);
7286
7287		let to_model = metadata.to_model_state();
7288		// Sanity: the FIXED macro contract must fold `null = "false"` into
7289		// `FieldState.nullable = false` for the PK, matching the migration
7290		// replay side.
7291		let to_id = to_model.fields.get("id").expect("id field present");
7292		assert!(
7293			!to_id.nullable,
7294			"to_state PK FieldState.nullable must be false; got nullable=true \
7295			 with params={:?}. Did the #[model] macro regress to emitting \
7296			 null=\"true\" for Option<T> PKs?",
7297			to_id.params
7298		);
7299
7300		let to_state = build_project_state(vec![(
7301			("clusters".to_string(), "Cluster".to_string()),
7302			to_model,
7303		)]);
7304
7305		// Build from_state via the production CLI path: feed a CreateTable
7306		// (modeled on 0001_initial.rs) through apply_migration_operations.
7307		// This populates from_state via column_def_to_field_state, which
7308		// derives nullability from `not_null` (sparse params).
7309		let create_clusters = super::super::Operation::CreateTable {
7310			name: "clusters".to_string(),
7311			columns: vec![
7312				super::super::ColumnDefinition {
7313					name: "id".to_string(),
7314					type_definition: super::super::FieldType::BigInteger,
7315					not_null: true,
7316					unique: false,
7317					primary_key: true,
7318					auto_increment: true,
7319					default: None,
7320				},
7321				super::super::ColumnDefinition {
7322					name: "name".to_string(),
7323					type_definition: super::super::FieldType::VarChar(255),
7324					not_null: true,
7325					unique: false,
7326					primary_key: false,
7327					auto_increment: false,
7328					default: None,
7329				},
7330			],
7331			constraints: vec![],
7332			without_rowid: None,
7333			interleave_in_parent: None,
7334			partition: None,
7335		};
7336		let mut from_state = ProjectState::new();
7337		from_state.apply_migration_operations(&[create_clusters], "clusters");
7338
7339		// Sanity: the migration-replay path must produce nullable=false for
7340		// the PK.
7341		let from_clusters = from_state
7342			.find_model_by_table("clusters")
7343			.expect("clusters model present in from_state");
7344		assert!(
7345			!from_clusters
7346				.fields
7347				.get("id")
7348				.expect("id field in from_state")
7349				.nullable,
7350			"from_state PK FieldState.nullable must be false (column_def_to_field_state derives \
7351			 from not_null); got nullable=true"
7352		);
7353
7354		let detector = MigrationAutodetector::new(from_state, to_state);
7355
7356		// Act — call BOTH lower-level generate_operations() and the CLI
7357		// entry generate_migrations(), since #4052's reproducer asserts
7358		// against both.
7359		let direct_ops = detector.generate_operations();
7360		let migrations = detector.generate_migrations();
7361		let migration_ops: Vec<&super::super::Operation> = migrations
7362			.iter()
7363			.flat_map(|m| m.operations.iter())
7364			.collect();
7365
7366		// Assert — neither path may emit AlterColumn for the unchanged `id`
7367		// PK. Pre-fix, both emitted exactly such an AlterColumn.
7368		assert!(
7369			!direct_ops.iter().any(|op| matches!(
7370				op,
7371				super::super::Operation::AlterColumn { column, .. } if column == "id"
7372			)),
7373			"generate_operations() emitted spurious AlterColumn for unchanged `id` PK \
7374			 under apply_migration_operations from_state. ops={:?}",
7375			direct_ops
7376		);
7377		assert!(
7378			!migration_ops.iter().any(|op| matches!(
7379				op,
7380				super::super::Operation::AlterColumn { column, .. } if column == "id"
7381			)),
7382			"generate_migrations() emitted spurious AlterColumn for unchanged `id` PK \
7383			 under apply_migration_operations from_state. ops={:?}",
7384			migration_ops
7385		);
7386	}
7387
7388	#[rstest]
7389	fn generate_migrations_emits_add_constraint_for_added_unique_together() {
7390		// Arrange — regression for issue #4040.
7391		//
7392		// `generate_migrations()` is the entry point used by the
7393		// `makemigrations` CLI, in contrast to `generate_operations()`
7394		// which is used by tests / direct callers. PR #3998 added
7395		// `added_constraints` handling to `generate_operations()` but the
7396		// symmetric loop was missing from `generate_migrations()`, so the
7397		// CLI silently dropped `Operation::AddConstraint` for non-PK
7398		// constraints (e.g. `unique_together`) added to existing models.
7399		//
7400		// This test exercises the CLI path directly and asserts the
7401		// migration carries an `Operation::AddConstraint`.
7402		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7403		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7404		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7405
7406		// from_state mirrors the CLI's offline-reconstructed state with no
7407		// constraints declared on the existing model (matching what the
7408		// `0001_initial` migration produces before the constraint is added).
7409		let mut from_model = build_model_state(
7410			"clusters",
7411			"Cluster",
7412			vec![id_field.clone(), org_field.clone(), name_field.clone()],
7413			Vec::new(),
7414			Vec::new(),
7415		);
7416		from_model.table_name = "clusters_cluster".to_string();
7417
7418		// to_state carries the unique_together constraint declared via the
7419		// macro on the registered struct.
7420		let unique_constraint = ConstraintDefinition {
7421			name: "clusters_cluster_organization_id_name_uniq".to_string(),
7422			constraint_type: "unique".to_string(),
7423			fields: vec!["organization_id".to_string(), "name".to_string()],
7424			expression: None,
7425			foreign_key_info: None,
7426		};
7427		let mut to_model = build_model_state(
7428			"clusters",
7429			"Cluster",
7430			vec![id_field, org_field, name_field],
7431			Vec::new(),
7432			vec![unique_constraint],
7433		);
7434		to_model.table_name = "clusters_cluster".to_string();
7435
7436		let from_state = build_project_state(vec![(
7437			("clusters".to_string(), "Cluster".to_string()),
7438			from_model,
7439		)]);
7440		let to_state = build_project_state(vec![(
7441			("clusters".to_string(), "Cluster".to_string()),
7442			to_model,
7443		)]);
7444		let detector = MigrationAutodetector::new(from_state, to_state);
7445
7446		// Act
7447		let migrations = detector.generate_migrations();
7448
7449		// Assert — exactly one Migration for app "clusters" with exactly
7450		// one AddConstraint operation, targeted at the shared table.
7451		assert_eq!(
7452			migrations.len(),
7453			1,
7454			"expected exactly one Migration, got: {:?}",
7455			migrations
7456		);
7457		assert_eq!(migrations[0].app_label, "clusters");
7458		assert_eq!(
7459			migrations[0].operations.len(),
7460			1,
7461			"expected exactly one operation in the migration, got: {:?}",
7462			migrations[0].operations
7463		);
7464		let super::super::Operation::AddConstraint {
7465			table,
7466			constraint_sql,
7467		} = &migrations[0].operations[0]
7468		else {
7469			panic!(
7470				"expected Operation::AddConstraint, got: {:?}",
7471				migrations[0].operations[0]
7472			);
7473		};
7474		assert_eq!(table, "clusters_cluster");
7475		assert!(
7476			constraint_sql.contains("clusters_cluster_organization_id_name_uniq"),
7477			"constraint SQL should carry the constraint name, got: {}",
7478			constraint_sql
7479		);
7480	}
7481
7482	#[rstest]
7483	fn generate_migrations_emits_drop_constraint_for_removed_unique_together() {
7484		// Arrange — symmetric regression for issue #4040 covering the
7485		// removal direction: the existing model declares a
7486		// `unique_together` constraint, the new struct has dropped it, and
7487		// the CLI path must emit `Operation::DropConstraint`.
7488		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7489		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7490		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7491
7492		let unique_constraint = ConstraintDefinition {
7493			name: "clusters_cluster_organization_id_name_uniq".to_string(),
7494			constraint_type: "unique".to_string(),
7495			fields: vec!["organization_id".to_string(), "name".to_string()],
7496			expression: None,
7497			foreign_key_info: None,
7498		};
7499		let mut from_model = build_model_state(
7500			"clusters",
7501			"Cluster",
7502			vec![id_field.clone(), org_field.clone(), name_field.clone()],
7503			Vec::new(),
7504			vec![unique_constraint],
7505		);
7506		from_model.table_name = "clusters_cluster".to_string();
7507
7508		let mut to_model = build_model_state(
7509			"clusters",
7510			"Cluster",
7511			vec![id_field, org_field, name_field],
7512			Vec::new(),
7513			Vec::new(),
7514		);
7515		to_model.table_name = "clusters_cluster".to_string();
7516
7517		let from_state = build_project_state(vec![(
7518			("clusters".to_string(), "Cluster".to_string()),
7519			from_model,
7520		)]);
7521		let to_state = build_project_state(vec![(
7522			("clusters".to_string(), "Cluster".to_string()),
7523			to_model,
7524		)]);
7525		let detector = MigrationAutodetector::new(from_state, to_state);
7526
7527		// Act
7528		let migrations = detector.generate_migrations();
7529
7530		// Assert
7531		assert_eq!(
7532			migrations.len(),
7533			1,
7534			"expected exactly one Migration, got: {:?}",
7535			migrations
7536		);
7537		assert_eq!(migrations[0].app_label, "clusters");
7538		assert_eq!(
7539			migrations[0].operations.len(),
7540			1,
7541			"expected exactly one operation in the migration, got: {:?}",
7542			migrations[0].operations
7543		);
7544		let super::super::Operation::DropConstraint {
7545			table,
7546			constraint_name,
7547		} = &migrations[0].operations[0]
7548		else {
7549			panic!(
7550				"expected Operation::DropConstraint, got: {:?}",
7551				migrations[0].operations[0]
7552			);
7553		};
7554		assert_eq!(table, "clusters_cluster");
7555		assert_eq!(
7556			constraint_name,
7557			"clusters_cluster_organization_id_name_uniq"
7558		);
7559	}
7560
7561	#[rstest]
7562	fn shared_per_app_emissions_are_consistent_between_generate_paths() {
7563		// Arrange — regression for issue #4040 (structural).
7564		//
7565		// Issue #4040 was caused by `generate_operations()` and
7566		// `generate_migrations()` carrying parallel-but-divergent
7567		// per-change-set emission loops; PR #3998 updated only the former.
7568		// After the structural fix both methods route through
7569		// `emit_shared_per_app_operations()`, so the shared subset of ops
7570		// (CreateTable / column ops / constraint ops / auto-increment
7571		// resets) MUST always agree.
7572		//
7573		// This test exercises a representative scenario: an existing model
7574		// gains a unique constraint AND a new column. Both emissions must
7575		// appear identically in both methods. M2M / rename / move
7576		// divergences are intentionally not exercised here because they
7577		// remain method-specific by design.
7578		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7579		let org_field = FieldState::new("organization_id", super::super::FieldType::Integer, false);
7580		let name_field = FieldState::new("name", super::super::FieldType::VarChar(255), false);
7581		let new_col = FieldState::new("region", super::super::FieldType::VarChar(64), false);
7582
7583		let mut from_model = build_model_state(
7584			"clusters",
7585			"Cluster",
7586			vec![id_field.clone(), org_field.clone(), name_field.clone()],
7587			Vec::new(),
7588			Vec::new(),
7589		);
7590		from_model.table_name = "clusters_cluster".to_string();
7591
7592		let unique_constraint = ConstraintDefinition {
7593			name: "clusters_cluster_organization_id_name_uniq".to_string(),
7594			constraint_type: "unique".to_string(),
7595			fields: vec!["organization_id".to_string(), "name".to_string()],
7596			expression: None,
7597			foreign_key_info: None,
7598		};
7599		let mut to_model = build_model_state(
7600			"clusters",
7601			"Cluster",
7602			vec![id_field, org_field, name_field, new_col],
7603			Vec::new(),
7604			vec![unique_constraint],
7605		);
7606		to_model.table_name = "clusters_cluster".to_string();
7607
7608		let from_state = build_project_state(vec![(
7609			("clusters".to_string(), "Cluster".to_string()),
7610			from_model,
7611		)]);
7612		let to_state = build_project_state(vec![(
7613			("clusters".to_string(), "Cluster".to_string()),
7614			to_model,
7615		)]);
7616		let detector = MigrationAutodetector::new(from_state, to_state);
7617
7618		// Act
7619		let ops = detector.generate_operations();
7620		let migrations = detector.generate_migrations();
7621
7622		// Assert — flatten migrations into the same shape as `ops` and
7623		// compare as multisets (order is determined by per-app dependency
7624		// sort, which is the same algorithm but called with a different
7625		// scope, so we compare unordered).
7626		let mig_ops: Vec<&super::super::Operation> = migrations
7627			.iter()
7628			.flat_map(|m| m.operations.iter())
7629			.collect();
7630
7631		assert_eq!(
7632			ops.len(),
7633			mig_ops.len(),
7634			"shared per-app emissions diverged between generate_operations() ({:?}) and generate_migrations() ({:?})",
7635			ops,
7636			mig_ops
7637		);
7638		// Every op produced by generate_operations() must also appear in
7639		// generate_migrations() output.
7640		for op in &ops {
7641			assert!(
7642				mig_ops.iter().any(|m| *m == op),
7643				"generate_operations() produced {:?} but generate_migrations() did not",
7644				op
7645			);
7646		}
7647		// And vice versa.
7648		for op in &mig_ops {
7649			assert!(
7650				ops.iter().any(|o| o == *op),
7651				"generate_migrations() produced {:?} but generate_operations() did not",
7652				op
7653			);
7654		}
7655	}
7656
7657	#[rstest]
7658	fn detect_added_composite_pk_does_not_double_emit_add_constraint() {
7659		// Arrange — adding a composite PK should be emitted by the
7660		// `CreateCompositePrimaryKey` path only. The new
7661		// `added_constraints` emitter must skip composite PKs to avoid
7662		// emitting a redundant AddConstraint alongside it.
7663		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7664		let tenant_field = FieldState::new("tenant_id", super::super::FieldType::Integer, false);
7665
7666		let from_model = build_model_state(
7667			"billing",
7668			"Invoice",
7669			vec![id_field.clone(), tenant_field.clone()],
7670			Vec::new(),
7671			Vec::new(),
7672		);
7673		let composite_pk = ConstraintDefinition {
7674			name: "billing_invoice_pkey".to_string(),
7675			constraint_type: "primary_key".to_string(),
7676			fields: vec!["id".to_string(), "tenant_id".to_string()],
7677			expression: None,
7678			foreign_key_info: None,
7679		};
7680		let to_model = build_model_state(
7681			"billing",
7682			"Invoice",
7683			vec![id_field, tenant_field],
7684			Vec::new(),
7685			vec![composite_pk],
7686		);
7687		let from_state = build_project_state(vec![(
7688			("billing".to_string(), "Invoice".to_string()),
7689			from_model,
7690		)]);
7691		let to_state = build_project_state(vec![(
7692			("billing".to_string(), "Invoice".to_string()),
7693			to_model,
7694		)]);
7695		let detector = MigrationAutodetector::new(from_state, to_state);
7696
7697		// Act
7698		let operations = detector.generate_operations();
7699
7700		// Assert — exactly one operation, and it must be the composite PK
7701		// path, not a duplicate AddConstraint.
7702		assert_eq!(operations.len(), 1, "got: {:?}", operations);
7703		assert!(
7704			matches!(
7705				&operations[0],
7706				super::super::Operation::CreateCompositePrimaryKey { columns, .. }
7707					if columns == &["id".to_string(), "tenant_id".to_string()]
7708			),
7709			"expected only CreateCompositePrimaryKey, got: {:?}",
7710			operations
7711		);
7712	}
7713
7714	/// Reproduces reinhardt-web#4448: when the file-based `from_state`
7715	/// reconstruction stores a column's uniqueness as `params["unique"] =
7716	/// "true"` (the path through `ProjectState::apply_migration_operations`
7717	/// → `column_def_to_field_state`), and the live model registry's
7718	/// `to_state` materialises the same column as a synthesised
7719	/// single-field `ConstraintDefinition`, the autodetector must NOT emit
7720	/// an `Operation::AddConstraint` for the redundant UNIQUE.
7721	#[rstest]
7722	fn inline_unique_param_on_from_side_does_not_emit_redundant_add_constraint() {
7723		// Arrange — `from_state` mimics what `apply_migration_operations`
7724		// produces for `0001_initial.rs`: the `username` column carries
7725		// `params["unique"] = "true"` and the model has NO peer constraint.
7726		let mut username_field =
7727			FieldState::new("username", super::super::FieldType::VarChar(150), false);
7728		username_field
7729			.params
7730			.insert("unique".to_string(), "true".to_string());
7731		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7732		let from_model = build_model_state(
7733			"users",
7734			"User",
7735			vec![id_field.clone(), username_field.clone()],
7736			Vec::new(),
7737			Vec::new(),
7738		);
7739
7740		// `to_state` mimics what `ModelMetadata::to_model_state()` produces:
7741		// the same inline-unique param PLUS a synthesised single-field
7742		// UNIQUE `ConstraintDefinition` named per the
7743		// `{app}_{model.to_lowercase()}_{field}_uniq` convention.
7744		let synthesised = ConstraintDefinition {
7745			name: "users_user_username_uniq".to_string(),
7746			constraint_type: "unique".to_string(),
7747			fields: vec!["username".to_string()],
7748			expression: None,
7749			foreign_key_info: None,
7750		};
7751		let to_model = build_model_state(
7752			"users",
7753			"User",
7754			vec![id_field, username_field],
7755			Vec::new(),
7756			vec![synthesised],
7757		);
7758
7759		let from_state = build_project_state(vec![(
7760			("users".to_string(), "User".to_string()),
7761			from_model,
7762		)]);
7763		let to_state =
7764			build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
7765		let detector = MigrationAutodetector::new(from_state, to_state);
7766
7767		// Act
7768		let operations = detector.generate_operations();
7769
7770		// Assert — no AddConstraint is emitted, because the column is
7771		// already covered by inline `params["unique"]` in `from_state`.
7772		assert!(
7773			operations
7774				.iter()
7775				.all(|op| !matches!(op, super::super::Operation::AddConstraint { .. })),
7776			"expected NO Operation::AddConstraint, got: {:?}",
7777			operations
7778		);
7779	}
7780
7781	/// Reproduces the DB-introspection variant of reinhardt-web#4448:
7782	/// `from_state` carries a single-field UNIQUE constraint with a
7783	/// dialect-specific auto-name (e.g. SQLite's
7784	/// `sqlite_autoindex_users_1`), and `to_state` declares the same
7785	/// column's UNIQUE with the model-derived name
7786	/// (`users_user_username_uniq`). The names differ but the semantics
7787	/// are identical — no `AddConstraint` must be emitted.
7788	#[rstest]
7789	fn single_field_unique_constraint_renames_do_not_emit_redundant_add_constraint() {
7790		// Arrange
7791		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7792		let username_field =
7793			FieldState::new("username", super::super::FieldType::VarChar(150), false);
7794		let auto_named = ConstraintDefinition {
7795			name: "sqlite_autoindex_users_1".to_string(),
7796			constraint_type: "unique".to_string(),
7797			fields: vec!["username".to_string()],
7798			expression: None,
7799			foreign_key_info: None,
7800		};
7801		let model_named = ConstraintDefinition {
7802			name: "users_user_username_uniq".to_string(),
7803			constraint_type: "unique".to_string(),
7804			fields: vec!["username".to_string()],
7805			expression: None,
7806			foreign_key_info: None,
7807		};
7808		let from_model = build_model_state(
7809			"users",
7810			"User",
7811			vec![id_field.clone(), username_field.clone()],
7812			Vec::new(),
7813			vec![auto_named],
7814		);
7815		let to_model = build_model_state(
7816			"users",
7817			"User",
7818			vec![id_field, username_field],
7819			Vec::new(),
7820			vec![model_named],
7821		);
7822		let from_state = build_project_state(vec![(
7823			("users".to_string(), "User".to_string()),
7824			from_model,
7825		)]);
7826		let to_state =
7827			build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
7828		let detector = MigrationAutodetector::new(from_state, to_state);
7829
7830		// Act
7831		let operations = detector.generate_operations();
7832
7833		// Assert — neither AddConstraint nor DropConstraint is emitted.
7834		// A pure rename of an internal constraint name on a single-column
7835		// UNIQUE is treated as a no-op; emitting either would be invalid on
7836		// SQLite (no ALTER TABLE ADD CONSTRAINT) and would leave duplicate
7837		// constraints on dialects that recreate the table.
7838		let constraint_ops: Vec<_> = operations
7839			.iter()
7840			.filter(|op| {
7841				matches!(
7842					op,
7843					super::super::Operation::AddConstraint { .. }
7844						| super::super::Operation::DropConstraint { .. }
7845				)
7846			})
7847			.collect();
7848		assert!(
7849			constraint_ops.is_empty(),
7850			"expected no Add/DropConstraint ops, got: {:?}",
7851			constraint_ops
7852		);
7853	}
7854
7855	/// Symmetric guard for reinhardt-web#4448: when `from_state` has a
7856	/// single-field UNIQUE constraint and `to_state` represents the same
7857	/// uniqueness inline via `params["unique"] = "true"` only, no
7858	/// `DropConstraint` must be emitted. Without the symmetric check in
7859	/// `detect_removed_constraints` the fix would shift the redundancy
7860	/// from `AddConstraint` into `DropConstraint`.
7861	#[rstest]
7862	fn from_side_unique_constraint_matched_by_inline_unique_on_to_side_emits_no_drop() {
7863		// Arrange
7864		let id_field = FieldState::new("id", super::super::FieldType::Integer, false);
7865		let mut username_field =
7866			FieldState::new("username", super::super::FieldType::VarChar(150), false);
7867		username_field
7868			.params
7869			.insert("unique".to_string(), "true".to_string());
7870		let unique_constraint = ConstraintDefinition {
7871			name: "users_user_username_uniq".to_string(),
7872			constraint_type: "unique".to_string(),
7873			fields: vec!["username".to_string()],
7874			expression: None,
7875			foreign_key_info: None,
7876		};
7877		// from_state: constraint present, field has NO inline unique param.
7878		let bare_username =
7879			FieldState::new("username", super::super::FieldType::VarChar(150), false);
7880		let from_model = build_model_state(
7881			"users",
7882			"User",
7883			vec![id_field.clone(), bare_username],
7884			Vec::new(),
7885			vec![unique_constraint],
7886		);
7887		// to_state: no peer constraint, inline param only.
7888		let to_model = build_model_state(
7889			"users",
7890			"User",
7891			vec![id_field, username_field],
7892			Vec::new(),
7893			Vec::new(),
7894		);
7895		let from_state = build_project_state(vec![(
7896			("users".to_string(), "User".to_string()),
7897			from_model,
7898		)]);
7899		let to_state =
7900			build_project_state(vec![(("users".to_string(), "User".to_string()), to_model)]);
7901		let detector = MigrationAutodetector::new(from_state, to_state);
7902
7903		// Act
7904		let operations = detector.generate_operations();
7905
7906		// Assert — no DropConstraint emitted; the inline `params["unique"]`
7907		// on the to-side already covers the column.
7908		assert!(
7909			operations
7910				.iter()
7911				.all(|op| !matches!(op, super::super::Operation::DropConstraint { .. })),
7912			"expected NO Operation::DropConstraint, got: {:?}",
7913			operations
7914		);
7915	}
7916
7917	/// Direct unit test for the dedup pass
7918	/// `MigrationAutodetector::dedup_redundant_unique_add_constraints`.
7919	/// Manually constructs a per-app operation list where an
7920	/// `Operation::AddColumn { column.unique = true }` is followed by a
7921	/// peer `Operation::AddConstraint` that ascribes a UNIQUE to the same
7922	/// column. The dedup pass must drop the redundant `AddConstraint`,
7923	/// regardless of how it got there. Second safety net for
7924	/// reinhardt-web#4448.
7925	#[rstest]
7926	fn dedup_pass_drops_add_constraint_redundant_with_unique_add_column() {
7927		// Arrange
7928		let ops = vec![
7929			super::super::Operation::AddColumn {
7930				table: "users".to_string(),
7931				column: super::super::ColumnDefinition {
7932					name: "username".to_string(),
7933					type_definition: super::super::FieldType::VarChar(150),
7934					not_null: true,
7935					unique: true,
7936					primary_key: false,
7937					auto_increment: false,
7938					default: None,
7939				},
7940				mysql_options: None,
7941			},
7942			super::super::Operation::AddConstraint {
7943				table: "users".to_string(),
7944				constraint_sql: "CONSTRAINT users_user_username_uniq UNIQUE (username)".to_string(),
7945			},
7946		];
7947		let mut by_app: std::collections::BTreeMap<String, Vec<super::super::Operation>> =
7948			std::collections::BTreeMap::new();
7949		by_app.insert("users".to_string(), ops);
7950
7951		// Act
7952		MigrationAutodetector::dedup_redundant_unique_add_constraints(&mut by_app);
7953
7954		// Assert — only AddColumn survives; the AddConstraint is dropped.
7955		let remaining = &by_app["users"];
7956		assert_eq!(
7957			remaining.len(),
7958			1,
7959			"expected one operation after dedup, got: {:?}",
7960			remaining
7961		);
7962		assert!(
7963			matches!(remaining[0], super::super::Operation::AddColumn { .. }),
7964			"expected the surviving op to be AddColumn, got: {:?}",
7965			remaining[0]
7966		);
7967	}
7968}