Skip to main content

qail_core/
build.rs

1//! Build-time QAIL validation module.
2//!
3//! This module provides compile-time validation for QAIL queries
4//! without requiring proc macros.
5//!
6//! # Usage in build.rs
7//!
8//! ```ignore
9//! // In your build.rs:
10//! fn main() {
11//!     qail_core::build::validate();
12//! }
13//! ```
14//!
15//! # Environment Variables
16//!
17//! - `QAIL=schema` - Validate against schema.qail file
18//! - `QAIL=live` - Validate against live database
19//! - `QAIL=false` - Skip validation
20
21use std::collections::HashMap;
22use std::fs;
23use std::path::Path;
24
25/// Foreign key relationship definition
26#[derive(Debug, Clone)]
27pub struct ForeignKey {
28    /// Column in this table that references another table
29    pub column: String,
30    /// Name of referenced table
31    pub ref_table: String,
32    /// Column in referenced table
33    pub ref_column: String,
34}
35
36/// Table schema information with column types and relations
37#[derive(Debug, Clone)]
38pub struct TableSchema {
39    pub name: String,
40    /// Column name -> Column type (e.g., "id" -> "UUID", "name" -> "TEXT")
41    pub columns: HashMap<String, String>,
42    /// Column name -> Access Policy (Default: "Public", can be "Protected")
43    pub policies: HashMap<String, String>,
44    /// Foreign key relationships to other tables
45    pub foreign_keys: Vec<ForeignKey>,
46}
47
48/// Parsed schema from schema.qail file
49#[derive(Debug, Default)]
50pub struct Schema {
51    pub tables: HashMap<String, TableSchema>,
52}
53
54impl Schema {
55    /// Parse a schema.qail file
56    pub fn parse_file(path: &str) -> Result<Self, String> {
57        let content = fs::read_to_string(path)
58            .map_err(|e| format!("Failed to read schema file '{}': {}", path, e))?;
59        Self::parse(&content)
60    }
61
62    /// Parse schema from string
63    pub fn parse(content: &str) -> Result<Self, String> {
64        let mut schema = Schema::default();
65        let mut current_table: Option<String> = None;
66        let mut current_columns: HashMap<String, String> = HashMap::new();
67        let mut current_policies: HashMap<String, String> = HashMap::new();
68        let mut current_fks: Vec<ForeignKey> = Vec::new();
69
70        for line in content.lines() {
71            let line = line.trim();
72            
73            // Skip comments and empty lines
74            if line.is_empty() || line.starts_with('#') {
75                continue;
76            }
77
78            // Table definition: table name {
79            if line.starts_with("table ") && line.ends_with('{') {
80                // Save previous table if any
81                if let Some(table_name) = current_table.take() {
82                    schema.tables.insert(table_name.clone(), TableSchema {
83                        name: table_name,
84                        columns: std::mem::take(&mut current_columns),
85                        policies: std::mem::take(&mut current_policies),
86                        foreign_keys: std::mem::take(&mut current_fks),
87                    });
88                }
89                
90                // Parse new table name
91                let name = line.trim_start_matches("table ")
92                    .trim_end_matches('{')
93                    .trim()
94                    .to_string();
95                current_table = Some(name);
96            }
97            // End of table definition
98            else if line == "}" {
99                if let Some(table_name) = current_table.take() {
100                    schema.tables.insert(table_name.clone(), TableSchema {
101                        name: table_name,
102                        columns: std::mem::take(&mut current_columns),
103                        policies: std::mem::take(&mut current_policies),
104                        foreign_keys: std::mem::take(&mut current_fks),
105                    });
106                }
107            }
108            // Column definition: column_name TYPE [constraints] [ref:table.column] [protected]
109            // Format from qail pull: "flow_name VARCHAR not_null"
110            // New format with FK: "user_id UUID ref:users.id"
111            // New format with Policy: "password_hash TEXT protected"
112            else if current_table.is_some() && !line.starts_with('#') && !line.is_empty() {
113                let parts: Vec<&str> = line.split_whitespace().collect();
114                if let Some(col_name) = parts.first() {
115                    // Second word is the type (default to TEXT if missing)
116                    let col_type = parts.get(1).copied().unwrap_or("TEXT").to_uppercase();
117                    current_columns.insert(col_name.to_string(), col_type);
118                    
119                    // Check for policies and foreign keys
120                    let mut policy = "Public".to_string();
121                    
122                    for part in parts.iter().skip(2) {
123                        if *part == "protected" {
124                            policy = "Protected".to_string();
125                        } else if let Some(ref_spec) = part.strip_prefix("ref:") {
126                            // Parse "table.column" or ">table.column"
127                            let ref_spec = ref_spec.trim_start_matches('>');
128                            if let Some((ref_table, ref_col)) = ref_spec.split_once('.') {
129                                current_fks.push(ForeignKey {
130                                    column: col_name.to_string(),
131                                    ref_table: ref_table.to_string(),
132                                    ref_column: ref_col.to_string(),
133                                });
134                            }
135                        }
136                    }
137                    current_policies.insert(col_name.to_string(), policy);
138                }
139            }
140        }
141
142        Ok(schema)
143    }
144
145    /// Check if table exists
146    pub fn has_table(&self, name: &str) -> bool {
147        self.tables.contains_key(name)
148    }
149
150    /// Get table schema
151    pub fn table(&self, name: &str) -> Option<&TableSchema> {
152        self.tables.get(name)
153    }
154    
155    /// Merge pending migrations into the schema
156    /// Scans migration directory for .sql files and extracts:
157    /// - CREATE TABLE statements
158    /// - ALTER TABLE ADD COLUMN statements
159    pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
160        use std::fs;
161        
162        let dir = Path::new(migrations_dir);
163        if !dir.exists() {
164            return Ok(0); // No migrations directory
165        }
166        
167        let mut merged_count = 0;
168        
169        // Walk migration directories (format: migrations/YYYYMMDD_name/up.sql)
170        let entries = fs::read_dir(dir)
171            .map_err(|e| format!("Failed to read migrations dir: {}", e))?;
172        
173        for entry in entries.flatten() {
174            let path = entry.path();
175            
176            // Check for up.sql in subdirectory
177            let up_sql = if path.is_dir() {
178                path.join("up.sql")
179            } else if path.extension().is_some_and(|e| e == "sql") {
180                path.clone()
181            } else {
182                continue;
183            };
184            
185            if up_sql.exists() {
186                let content = fs::read_to_string(&up_sql)
187                    .map_err(|e| format!("Failed to read {}: {}", up_sql.display(), e))?;
188                
189                merged_count += self.parse_sql_migration(&content);
190            }
191        }
192        
193        Ok(merged_count)
194    }
195    
196    /// Parse SQL migration content and extract schema changes
197    fn parse_sql_migration(&mut self, sql: &str) -> usize {
198        let mut changes = 0;
199        
200        // Extract CREATE TABLE statements
201        // Pattern: CREATE TABLE [IF NOT EXISTS] table_name (columns...)
202        for line in sql.lines() {
203            let line_upper = line.trim().to_uppercase();
204            
205            if line_upper.starts_with("CREATE TABLE")
206                && let Some(table_name) = extract_create_table_name(line)
207                && !self.tables.contains_key(&table_name)
208            {
209                self.tables.insert(table_name.clone(), TableSchema {
210                    name: table_name,
211                    columns: HashMap::new(),
212                    policies: HashMap::new(),
213                    foreign_keys: vec![],
214                });
215                changes += 1;
216            }
217        }
218        
219        // Extract column definitions from CREATE TABLE blocks
220        let mut current_table: Option<String> = None;
221        let mut in_create_block = false;
222        let mut paren_depth = 0;
223        
224        for line in sql.lines() {
225            let line = line.trim();
226            let line_upper = line.to_uppercase();
227            
228            if line_upper.starts_with("CREATE TABLE")
229                && let Some(name) = extract_create_table_name(line)
230            {
231                current_table = Some(name);
232                in_create_block = true;
233                paren_depth = 0;
234            }
235            
236            if in_create_block {
237                paren_depth += line.chars().filter(|c| *c == '(').count();
238                paren_depth = paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
239                
240                // Extract column name (first identifier after opening paren)
241                if let Some(col) = extract_column_from_create(line)
242                    && let Some(ref table) = current_table
243                    && let Some(t) = self.tables.get_mut(table)
244                    && t.columns.insert(col.clone(), "TEXT".to_string()).is_none()
245                {
246                    changes += 1;
247                }
248                
249                if paren_depth == 0 && line.contains(')') {
250                    in_create_block = false;
251                    current_table = None;
252                }
253            }
254            
255            // ALTER TABLE ... ADD COLUMN
256            if line_upper.contains("ALTER TABLE") && line_upper.contains("ADD COLUMN")
257                && let Some((table, col)) = extract_alter_add_column(line)
258            {
259                if let Some(t) = self.tables.get_mut(&table) {
260                    if t.columns.insert(col.clone(), "TEXT".to_string()).is_none() {
261                        changes += 1;
262                    }
263                } else {
264                    // Table might be new from this migration
265                    let mut cols = HashMap::new();
266                    cols.insert(col, "TEXT".to_string());
267                    self.tables.insert(table.clone(), TableSchema {
268                        name: table,
269                        columns: cols,
270                        policies: HashMap::new(),
271                        foreign_keys: vec![],
272                    });
273                    changes += 1;
274                }
275            }
276            
277            // ALTER TABLE ... ADD (without COLUMN keyword)
278            if line_upper.contains("ALTER TABLE") && line_upper.contains(" ADD ") && !line_upper.contains("ADD COLUMN")
279                && let Some((table, col)) = extract_alter_add(line)
280                && let Some(t) = self.tables.get_mut(&table)
281                && t.columns.insert(col.clone(), "TEXT".to_string()).is_none()
282            {
283                changes += 1;
284            }
285            
286            // DROP TABLE
287            if line_upper.starts_with("DROP TABLE")
288                && let Some(table_name) = extract_drop_table_name(line)
289                && self.tables.remove(&table_name).is_some()
290            {
291                changes += 1;
292            }
293            
294            // ALTER TABLE ... DROP COLUMN
295            if line_upper.contains("ALTER TABLE") && line_upper.contains("DROP COLUMN")
296                && let Some((table, col)) = extract_alter_drop_column(line)
297                && let Some(t) = self.tables.get_mut(&table)
298                && t.columns.remove(&col).is_some()
299            {
300                changes += 1;
301            }
302            
303            // ALTER TABLE ... DROP (without COLUMN keyword - PostgreSQL style)
304            if line_upper.contains("ALTER TABLE") && line_upper.contains(" DROP ") 
305                && !line_upper.contains("DROP COLUMN") 
306                && !line_upper.contains("DROP CONSTRAINT")
307                && !line_upper.contains("DROP INDEX")
308                && let Some((table, col)) = extract_alter_drop(line)
309                && let Some(t) = self.tables.get_mut(&table)
310                && t.columns.remove(&col).is_some()
311            {
312                changes += 1;
313            }
314        }
315        
316        changes
317    }
318}
319
320/// Extract table name from CREATE TABLE statement
321fn extract_create_table_name(line: &str) -> Option<String> {
322    let line_upper = line.to_uppercase();
323    let rest = line_upper.strip_prefix("CREATE TABLE")?;
324    let rest = rest.trim_start();
325    let rest = if rest.starts_with("IF NOT EXISTS") {
326        rest.strip_prefix("IF NOT EXISTS")?.trim_start()
327    } else {
328        rest
329    };
330    
331    // Get table name (first identifier)
332    let name: String = line[line.len() - rest.len()..]
333        .chars()
334        .take_while(|c| c.is_alphanumeric() || *c == '_')
335        .collect();
336    
337    if name.is_empty() { None } else { Some(name.to_lowercase()) }
338}
339
340/// Extract column name from a line inside CREATE TABLE block
341fn extract_column_from_create(line: &str) -> Option<String> {
342    let line = line.trim();
343    
344    // Skip keywords and constraints
345    let line_upper = line.to_uppercase();
346    if line_upper.starts_with("CREATE") || 
347       line_upper.starts_with("PRIMARY") ||
348       line_upper.starts_with("FOREIGN") ||
349       line_upper.starts_with("UNIQUE") ||
350       line_upper.starts_with("CHECK") ||
351       line_upper.starts_with("CONSTRAINT") ||
352       line_upper.starts_with(")") ||
353       line_upper.starts_with("(") ||
354       line.is_empty() {
355        return None;
356    }
357    
358    // First word is column name
359    let name: String = line
360        .trim_start_matches('(')
361        .trim()
362        .chars()
363        .take_while(|c| c.is_alphanumeric() || *c == '_')
364        .collect();
365    
366    if name.is_empty() || name.to_uppercase() == "IF" { None } else { Some(name.to_lowercase()) }
367}
368
369/// Extract table and column from ALTER TABLE ... ADD COLUMN
370fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
371    let line_upper = line.to_uppercase();
372    let alter_pos = line_upper.find("ALTER TABLE")?;
373    let add_pos = line_upper.find("ADD COLUMN")?;
374    
375    // Table name between ALTER TABLE and ADD COLUMN
376    let table_part = &line[alter_pos + 11..add_pos];
377    let table: String = table_part.trim()
378        .chars()
379        .take_while(|c| c.is_alphanumeric() || *c == '_')
380        .collect();
381    
382    // Column name after ADD COLUMN
383    let col_part = &line[add_pos + 10..];
384    let col: String = col_part.trim()
385        .chars()
386        .take_while(|c| c.is_alphanumeric() || *c == '_')
387        .collect();
388    
389    if table.is_empty() || col.is_empty() {
390        None
391    } else {
392        Some((table.to_lowercase(), col.to_lowercase()))
393    }
394}
395
396/// Extract table and column from ALTER TABLE ... ADD (without COLUMN keyword)
397fn extract_alter_add(line: &str) -> Option<(String, String)> {
398    let line_upper = line.to_uppercase();
399    let alter_pos = line_upper.find("ALTER TABLE")?;
400    let add_pos = line_upper.find(" ADD ")?;
401    
402    let table_part = &line[alter_pos + 11..add_pos];
403    let table: String = table_part.trim()
404        .chars()
405        .take_while(|c| c.is_alphanumeric() || *c == '_')
406        .collect();
407    
408    let col_part = &line[add_pos + 5..];
409    let col: String = col_part.trim()
410        .chars()
411        .take_while(|c| c.is_alphanumeric() || *c == '_')
412        .collect();
413    
414    if table.is_empty() || col.is_empty() {
415        None
416    } else {
417        Some((table.to_lowercase(), col.to_lowercase()))
418    }
419}
420
421/// Extract table name from DROP TABLE statement
422fn extract_drop_table_name(line: &str) -> Option<String> {
423    let line_upper = line.to_uppercase();
424    let rest = line_upper.strip_prefix("DROP TABLE")?;
425    let rest = rest.trim_start();
426    let rest = if rest.starts_with("IF EXISTS") {
427        rest.strip_prefix("IF EXISTS")?.trim_start()
428    } else {
429        rest
430    };
431    
432    // Get table name (first identifier)
433    let name: String = line[line.len() - rest.len()..]
434        .chars()
435        .take_while(|c| c.is_alphanumeric() || *c == '_')
436        .collect();
437    
438    if name.is_empty() { None } else { Some(name.to_lowercase()) }
439}
440
441/// Extract table and column from ALTER TABLE ... DROP COLUMN
442fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
443    let line_upper = line.to_uppercase();
444    let alter_pos = line_upper.find("ALTER TABLE")?;
445    let drop_pos = line_upper.find("DROP COLUMN")?;
446    
447    // Table name between ALTER TABLE and DROP COLUMN
448    let table_part = &line[alter_pos + 11..drop_pos];
449    let table: String = table_part.trim()
450        .chars()
451        .take_while(|c| c.is_alphanumeric() || *c == '_')
452        .collect();
453    
454    // Column name after DROP COLUMN
455    let col_part = &line[drop_pos + 11..];
456    let col: String = col_part.trim()
457        .chars()
458        .take_while(|c| c.is_alphanumeric() || *c == '_')
459        .collect();
460    
461    if table.is_empty() || col.is_empty() {
462        None
463    } else {
464        Some((table.to_lowercase(), col.to_lowercase()))
465    }
466}
467
468/// Extract table and column from ALTER TABLE ... DROP (without COLUMN keyword)
469fn extract_alter_drop(line: &str) -> Option<(String, String)> {
470    let line_upper = line.to_uppercase();
471    let alter_pos = line_upper.find("ALTER TABLE")?;
472    let drop_pos = line_upper.find(" DROP ")?;
473    
474    let table_part = &line[alter_pos + 11..drop_pos];
475    let table: String = table_part.trim()
476        .chars()
477        .take_while(|c| c.is_alphanumeric() || *c == '_')
478        .collect();
479    
480    let col_part = &line[drop_pos + 6..];
481    let col: String = col_part.trim()
482        .chars()
483        .take_while(|c| c.is_alphanumeric() || *c == '_')
484        .collect();
485    
486    if table.is_empty() || col.is_empty() {
487        None
488    } else {
489        Some((table.to_lowercase(), col.to_lowercase()))
490    }
491}
492
493impl TableSchema {
494    /// Check if column exists
495    pub fn has_column(&self, name: &str) -> bool {
496        self.columns.contains_key(name)
497    }
498    
499    /// Get column type
500    pub fn column_type(&self, name: &str) -> Option<&str> {
501        self.columns.get(name).map(|s| s.as_str())
502    }
503}
504
505/// Extracted QAIL usage from source code
506#[derive(Debug)]
507pub struct QailUsage {
508    pub file: String,
509    pub line: usize,
510    pub table: String,
511    pub columns: Vec<String>,
512    pub action: String,
513    pub is_cte_ref: bool,
514}
515
516/// Scan Rust source files for QAIL usage patterns
517pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
518    let mut usages = Vec::new();
519    scan_directory(Path::new(src_dir), &mut usages);
520    usages
521}
522
523fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
524    if let Ok(entries) = fs::read_dir(dir) {
525        for entry in entries.flatten() {
526            let path = entry.path();
527            if path.is_dir() {
528                scan_directory(&path, usages);
529            } else if path.extension().is_some_and(|e| e == "rs")
530                && let Ok(content) = fs::read_to_string(&path)
531            {
532                scan_file(&path.display().to_string(), &content, usages);
533            }
534        }
535    }
536}
537
538fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
539    // Patterns to match:
540    // Qail::get("table")
541    // Qail::add("table")
542    // Qail::del("table")
543    // Qail::put("table")
544    
545    let patterns = [
546        ("Qail::get(", "GET"),
547        ("Qail::add(", "ADD"),
548        ("Qail::del(", "DEL"),
549        ("Qail::put(", "PUT"),
550    ];
551
552    // First pass: extract all CTE names from .to_cte() patterns
553    // Pattern: .to_cte("cte_name")
554    let mut cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
555    for line in content.lines() {
556        let line = line.trim();
557        if let Some(pos) = line.find(".to_cte(") {
558            let after = &line[pos + 8..]; // ".to_cte(" is 8 chars
559            if let Some(name) = extract_string_arg(after) {
560                cte_names.insert(name);
561            }
562        }
563    }
564
565    // Second pass: detect Qail usage and mark CTE refs
566    let lines: Vec<&str> = content.lines().collect();
567    let mut i = 0;
568    
569    while i < lines.len() {
570        let line = lines[i].trim();
571        
572        // Check if this line starts a Qail chain
573        for (pattern, action) in &patterns {
574            if let Some(pos) = line.find(pattern) {
575                let start_line = i + 1; // 1-indexed
576                
577                // Extract table name from Qail::get("table")
578                let after = &line[pos + pattern.len()..];
579                if let Some(table) = extract_string_arg(after) {
580                    // Join continuation lines (lines that start with .)
581                    let mut full_chain = line.to_string();
582                    let mut j = i + 1;
583                    while j < lines.len() {
584                        let next = lines[j].trim();
585                        if next.starts_with('.') {
586                            full_chain.push_str(next);
587                            j += 1;
588                        } else if next.is_empty() {
589                            j += 1; // Skip empty lines
590                        } else {
591                            break;
592                        }
593                    }
594                    
595                    // Check if this is a CTE reference
596                    let is_cte_ref = cte_names.contains(&table);
597                    
598                    // Extract column names from the full chain
599                    let columns = extract_columns(&full_chain);
600                    
601                    usages.push(QailUsage {
602                        file: file.to_string(),
603                        line: start_line,
604                        table,
605                        columns,
606                        action: action.to_string(),
607                        is_cte_ref,
608                    });
609                    
610                    // Skip to end of chain
611                    i = j.saturating_sub(1);
612                }
613                break; // Only match one pattern per line
614            }
615        }
616        i += 1;
617    }
618}
619
620fn extract_string_arg(s: &str) -> Option<String> {
621    // Find "string" pattern
622    let s = s.trim();
623    if let Some(stripped) = s.strip_prefix('"') {
624        let end = stripped.find('"')?;
625        Some(stripped[..end].to_string())
626    } else {
627        None
628    }
629}
630
631fn extract_columns(line: &str) -> Vec<String> {
632    let mut columns = Vec::new();
633    let mut remaining = line;
634    
635    // .column("col")
636    while let Some(pos) = remaining.find(".column(") {
637        let after = &remaining[pos + 8..];
638        if let Some(col) = extract_string_arg(after) {
639            columns.push(col);
640        }
641        remaining = after;
642    }
643    
644    // Reset for next pattern
645    remaining = line;
646    
647    // .filter("col", ...)
648    while let Some(pos) = remaining.find(".filter(") {
649        let after = &remaining[pos + 8..];
650        if let Some(col) = extract_string_arg(after)
651            && !col.contains('.') {
652            columns.push(col);
653        }
654        remaining = after;
655    }
656    
657    // .eq("col", val), .ne("col", val), .gt, .lt, .gte, .lte
658    for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
659        let mut temp = line;
660        while let Some(pos) = temp.find(method) {
661            let after = &temp[pos + method.len()..];
662            if let Some(col) = extract_string_arg(after)
663                && !col.contains('.') {
664                columns.push(col);
665            }
666            temp = after;
667        }
668    }
669    
670    // .order_by("col", ...)
671    let mut remaining = line;
672    while let Some(pos) = remaining.find(".order_by(") {
673        let after = &remaining[pos + 10..];
674        if let Some(col) = extract_string_arg(after)
675            && !col.contains('.') {
676            columns.push(col);
677        }
678        remaining = after;
679    }
680    
681    columns
682}
683
684/// Validate QAIL usage against schema using the smart Validator
685/// Provides "Did you mean?" suggestions for typos and type validation
686pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
687    use crate::validator::Validator;
688    
689    // Build Validator from Schema with column types
690    let mut validator = Validator::new();
691    for (table_name, table_schema) in &schema.tables {
692        // Convert HashMap<String, String> to Vec<(&str, &str)>
693        let cols_with_types: Vec<(&str, &str)> = table_schema.columns
694            .iter()
695            .map(|(name, typ)| (name.as_str(), typ.as_str()))
696            .collect();
697        validator.add_table_with_types(table_name, &cols_with_types);
698    }
699    
700    let mut errors = Vec::new();
701
702    for usage in usages {
703        // Skip CTE alias refs - these are defined in code, not in schema
704        if usage.is_cte_ref {
705            continue;
706        }
707        
708        // Use Validator for smart error messages with suggestions
709        match validator.validate_table(&usage.table) {
710            Ok(()) => {
711                // Table exists, check columns
712                for col in &usage.columns {
713                    // Skip qualified columns (CTE refs like cte.column)
714                    if col.contains('.') {
715                        continue;
716                    }
717                    
718                    if let Err(e) = validator.validate_column(&usage.table, col) {
719                        errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
720                    }
721                }
722            }
723            Err(e) => {
724                errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
725            }
726        }
727    }
728
729    errors
730}
731
732/// Main validation entry point for build.rs
733pub fn validate() {
734    let mode = std::env::var("QAIL").unwrap_or_else(|_| {
735        if Path::new("schema.qail").exists() {
736            "schema".to_string()
737        } else {
738            "false".to_string()
739        }
740    });
741
742    match mode.as_str() {
743        "schema" => {
744            println!("cargo:rerun-if-changed=schema.qail");
745            println!("cargo:rerun-if-changed=migrations");
746            println!("cargo:rerun-if-env-changed=QAIL");
747            
748            match Schema::parse_file("schema.qail") {
749                Ok(mut schema) => {
750                    // Merge pending migrations with pulled schema
751                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
752                    if merged > 0 {
753                        println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
754                    }
755                    
756                    let usages = scan_source_files("src/");
757                    let errors = validate_against_schema(&schema, &usages);
758                    
759                    if errors.is_empty() {
760                        println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
761                    } else {
762                        for error in &errors {
763                            println!("cargo:warning=QAIL ERROR: {}", error);
764                        }
765                        // Fail the build
766                        panic!("QAIL validation failed with {} errors", errors.len());
767                    }
768                }
769                Err(e) => {
770                    println!("cargo:warning=QAIL: {}", e);
771                }
772            }
773        }
774        "live" => {
775            println!("cargo:rerun-if-env-changed=QAIL");
776            println!("cargo:rerun-if-env-changed=DATABASE_URL");
777            
778            // Get DATABASE_URL for qail pull
779            let db_url = match std::env::var("DATABASE_URL") {
780                Ok(url) => url,
781                Err(_) => {
782                    panic!("QAIL=live requires DATABASE_URL environment variable");
783                }
784            };
785            
786            // Step 1: Run qail pull to update schema.qail
787            println!("cargo:warning=QAIL: Pulling schema from live database...");
788            
789            let pull_result = std::process::Command::new("qail")
790                .args(["pull", &db_url])
791                .output();
792            
793            match pull_result {
794                Ok(output) => {
795                    if !output.status.success() {
796                        let stderr = String::from_utf8_lossy(&output.stderr);
797                        panic!("QAIL: Failed to pull schema: {}", stderr);
798                    }
799                    println!("cargo:warning=QAIL: Schema pulled successfully ✓");
800                }
801                Err(e) => {
802                    // qail CLI not found, try using cargo run
803                    println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
804                    
805                    let cargo_result = std::process::Command::new("cargo")
806                        .args(["run", "-p", "qail", "--", "pull", &db_url])
807                        .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
808                        .output();
809                    
810                    match cargo_result {
811                        Ok(output) if output.status.success() => {
812                            println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
813                        }
814                        _ => {
815                            panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
816                        }
817                    }
818                }
819            }
820            
821            // Step 2: Parse the updated schema and validate
822            match Schema::parse_file("schema.qail") {
823                Ok(mut schema) => {
824                    // Merge pending migrations (in case live DB doesn't have them yet)
825                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
826                    if merged > 0 {
827                        println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
828                    }
829                    
830                    let usages = scan_source_files("src/");
831                    let errors = validate_against_schema(&schema, &usages);
832                    
833                    if errors.is_empty() {
834                        println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
835                    } else {
836                        for error in &errors {
837                            println!("cargo:warning=QAIL ERROR: {}", error);
838                        }
839                        panic!("QAIL validation failed with {} errors", errors.len());
840                    }
841                }
842                Err(e) => {
843                    panic!("QAIL: Failed to parse schema after pull: {}", e);
844                }
845            }
846        }
847        "false" | "off" | "0" => {
848            println!("cargo:rerun-if-env-changed=QAIL");
849            // Silently skip validation
850        }
851        _ => {
852            panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
853        }
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860
861    #[test]
862    fn test_parse_schema() {
863        // Format matches qail pull output (space-separated, not colon)
864        let content = r#"
865# Test schema
866
867table users {
868  id UUID primary_key
869  name TEXT not_null
870  email TEXT unique
871}
872
873table posts {
874  id UUID
875  user_id UUID
876  title TEXT
877}
878"#;
879        let schema = Schema::parse(content).unwrap();
880        assert!(schema.has_table("users"));
881        assert!(schema.has_table("posts"));
882        assert!(schema.table("users").unwrap().has_column("id"));
883        assert!(schema.table("users").unwrap().has_column("name"));
884        assert!(!schema.table("users").unwrap().has_column("foo"));
885    }
886
887    #[test]
888    fn test_extract_string_arg() {
889        assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
890        assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
891    }
892
893    #[test]
894    fn test_scan_file() {
895        // Test single-line pattern
896        let content = r#"
897let query = Qail::get("users").column("id").column("name").eq("active", true);
898"#;
899        let mut usages = Vec::new();
900        scan_file("test.rs", content, &mut usages);
901        
902        assert_eq!(usages.len(), 1);
903        assert_eq!(usages[0].table, "users");
904        assert_eq!(usages[0].action, "GET");
905        assert!(usages[0].columns.contains(&"id".to_string()));
906        assert!(usages[0].columns.contains(&"name".to_string()));
907    }
908
909    #[test]
910    fn test_scan_file_multiline() {
911        // Test multi-line chain pattern (common in real code)
912        let content = r#"
913let query = Qail::get("posts")
914    .column("id")
915    .column("title")
916    .column("author")
917    .eq("published", true)
918    .order_by("created_at", Desc);
919"#;
920        let mut usages = Vec::new();
921        scan_file("test.rs", content, &mut usages);
922        
923        assert_eq!(usages.len(), 1);
924        assert_eq!(usages[0].table, "posts");
925        assert_eq!(usages[0].action, "GET");
926        assert!(usages[0].columns.contains(&"id".to_string()));
927        assert!(usages[0].columns.contains(&"title".to_string()));
928        assert!(usages[0].columns.contains(&"author".to_string()));
929    }
930}
931
932// =============================================================================
933// Typed Schema Codegen
934// =============================================================================
935
936/// Map QAIL types to Rust types for TypedColumn<T>
937fn qail_type_to_rust(qail_type: &str) -> &'static str {
938    match qail_type.to_uppercase().as_str() {
939        "UUID" => "uuid::Uuid",
940        "TEXT" | "VARCHAR" | "CHAR" | "STRING" => "String",
941        "INT" | "INTEGER" | "INT4" | "SERIAL" => "i32",
942        "BIGINT" | "INT8" | "BIGSERIAL" => "i64",
943        "SMALLINT" | "INT2" => "i16",
944        "FLOAT" | "FLOAT4" | "REAL" => "f32",
945        "DOUBLE" | "FLOAT8" | "DOUBLE PRECISION" => "f64",
946        "DECIMAL" | "NUMERIC" => "rust_decimal::Decimal",
947        "BOOL" | "BOOLEAN" => "bool",
948        "TIMESTAMP" | "TIMESTAMPTZ" => "chrono::DateTime<chrono::Utc>",
949        "DATE" => "chrono::NaiveDate",
950        "TIME" | "TIMETZ" => "chrono::NaiveTime",
951        "JSON" | "JSONB" => "serde_json::Value",
952        "BYTEA" | "BLOB" => "Vec<u8>",
953        _ => "String", // Default to String for unknown types
954    }
955}
956
957/// Convert table/column names to valid Rust identifiers
958fn to_rust_ident(name: &str) -> String {
959    // Handle Rust keywords
960    let name = match name {
961        "type" => "r#type",
962        "match" => "r#match",
963        "ref" => "r#ref",
964        "self" => "r#self",
965        "mod" => "r#mod",
966        "use" => "r#use",
967        _ => name,
968    };
969    name.to_string()
970}
971
972/// Convert table name to PascalCase struct name
973fn to_struct_name(name: &str) -> String {
974    name.chars()
975        .next()
976        .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
977        .unwrap_or_default()
978}
979
980/// Generate typed Rust module from schema.
981/// 
982/// # Usage in consumer's build.rs:
983/// ```ignore
984/// fn main() {
985///     let out_dir = std::env::var("OUT_DIR").unwrap();
986///     qail_core::build::generate_typed_schema("schema.qail", &format!("{}/schema.rs", out_dir)).unwrap();
987///     println!("cargo:rerun-if-changed=schema.qail");
988/// }
989/// ```
990/// 
991/// Then in the consumer's lib.rs:
992/// ```ignore
993/// include!(concat!(env!("OUT_DIR"), "/schema.rs"));
994/// ```
995pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
996    let schema = Schema::parse_file(schema_path)?;
997    let code = generate_schema_code(&schema);
998    
999    fs::write(output_path, code)
1000        .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
1001    
1002    Ok(())
1003}
1004
1005/// Generate typed Rust code from schema (does not write to file)
1006pub fn generate_schema_code(schema: &Schema) -> String {
1007    let mut code = String::new();
1008    
1009    // Header
1010    code.push_str("//! Auto-generated typed schema from schema.qail\n");
1011    code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
1012    code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
1013    code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
1014    
1015    // Sort tables for deterministic output
1016    let mut tables: Vec<_> = schema.tables.values().collect();
1017    tables.sort_by(|a, b| a.name.cmp(&b.name));
1018    
1019    for table in &tables {
1020        let mod_name = to_rust_ident(&table.name);
1021        let struct_name = to_struct_name(&table.name);
1022        
1023        code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
1024        code.push_str(&format!("pub mod {} {{\n", mod_name));
1025        code.push_str("    use super::*;\n\n");
1026        
1027        // Table struct implementing Table trait
1028        code.push_str(&format!("    /// Table marker for `{}`\n", table.name));
1029        code.push_str("    #[derive(Debug, Clone, Copy)]\n");
1030        code.push_str(&format!("    pub struct {};\n\n", struct_name));
1031        
1032        code.push_str(&format!("    impl Table for {} {{\n", struct_name));
1033        code.push_str(&format!("        fn table_name() -> &'static str {{ \"{}\" }}\n", table.name));
1034        code.push_str("    }\n\n");
1035        
1036        code.push_str(&format!("    impl From<{}> for String {{\n", struct_name));
1037        code.push_str(&format!("        fn from(_: {}) -> String {{ \"{}\".to_string() }}\n", struct_name, table.name));
1038        code.push_str("    }\n\n");
1039
1040        code.push_str(&format!("    impl AsRef<str> for {} {{\n", struct_name));
1041        code.push_str(&format!("        fn as_ref(&self) -> &str {{ \"{}\" }}\n", table.name));
1042        code.push_str("    }\n\n");
1043        
1044        // Table constant for convenience
1045        code.push_str(&format!("    /// The `{}` table\n", table.name));
1046        code.push_str(&format!("    pub const table: {} = {};\n\n", struct_name, struct_name));
1047        
1048        // Sort columns for deterministic output
1049        let mut columns: Vec<_> = table.columns.iter().collect();
1050        columns.sort_by(|a, b| a.0.cmp(b.0));
1051        
1052        // Column constants
1053        for (col_name, col_type) in columns {
1054            let rust_type = qail_type_to_rust(col_type);
1055            let col_ident = to_rust_ident(col_name);
1056            let policy = table.policies.get(col_name).map(|s| s.as_str()).unwrap_or("Public");
1057            let rust_policy = if policy == "Protected" { "Protected" } else { "Public" };
1058            
1059            code.push_str(&format!("    /// Column `{}.{}` ({}) - {}\n", table.name, col_name, col_type, policy));
1060            code.push_str(&format!(
1061                "    pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
1062                col_ident, rust_type, rust_policy, table.name, col_name
1063            ));
1064        }
1065        
1066        code.push_str("}\n\n");
1067    }
1068    
1069    // ==========================================================================
1070    // Generate RelatedTo impls for compile-time relationship checking
1071    // ==========================================================================
1072    
1073    code.push_str("// =============================================================================\n");
1074    code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
1075    code.push_str("// =============================================================================\n\n");
1076    
1077    for table in &tables {
1078        for fk in &table.foreign_keys {
1079            // table.column refs ref_table.ref_column
1080            // This means: table is related TO ref_table (forward)
1081            // AND: ref_table is related FROM table (reverse - parent has many children)
1082            
1083            let from_mod = to_rust_ident(&table.name);
1084            let from_struct = to_struct_name(&table.name);
1085            let to_mod = to_rust_ident(&fk.ref_table);
1086            let to_struct = to_struct_name(&fk.ref_table);
1087            
1088            // Forward: From table (child) -> Referenced table (parent)
1089            // Example: posts -> users (posts.user_id -> users.id)
1090            code.push_str(&format!(
1091                "/// {} has a foreign key to {} via {}.{}\n",
1092                table.name, fk.ref_table, table.name, fk.column
1093            ));
1094            code.push_str(&format!(
1095                "impl RelatedTo<{}::{}> for {}::{} {{\n",
1096                to_mod, to_struct, from_mod, from_struct
1097            ));
1098            code.push_str(&format!(
1099                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1100                fk.column, fk.ref_column
1101            ));
1102            code.push_str("}\n\n");
1103            
1104            // Reverse: Referenced table (parent) -> From table (child)
1105            // Example: users -> posts (users.id -> posts.user_id)
1106            // This allows: Qail::get(users::table).join_related(posts::table)
1107            code.push_str(&format!(
1108                "/// {} is referenced by {} via {}.{}\n",
1109                fk.ref_table, table.name, table.name, fk.column
1110            ));
1111            code.push_str(&format!(
1112                "impl RelatedTo<{}::{}> for {}::{} {{\n",
1113                from_mod, from_struct, to_mod, to_struct
1114            ));
1115            code.push_str(&format!(
1116                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1117                fk.ref_column, fk.column
1118            ));
1119            code.push_str("}\n\n");
1120        }
1121    }
1122    
1123    code
1124}
1125
1126#[cfg(test)]
1127mod codegen_tests {
1128    use super::*;
1129    
1130    #[test]
1131    fn test_generate_schema_code() {
1132        let schema_content = r#"
1133table users {
1134    id UUID primary_key
1135    email TEXT not_null
1136    age INT
1137}
1138
1139table posts {
1140    id UUID primary_key
1141    user_id UUID ref:users.id
1142    title TEXT
1143}
1144"#;
1145        
1146        let schema = Schema::parse(schema_content).unwrap();
1147        let code = generate_schema_code(&schema);
1148        
1149        // Verify module structure
1150        assert!(code.contains("pub mod users {"));
1151        assert!(code.contains("pub mod posts {"));
1152        
1153        // Verify table structs
1154        assert!(code.contains("pub struct Users;"));
1155        assert!(code.contains("pub struct Posts;"));
1156        
1157        // Verify columns
1158        assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
1159        assert!(code.contains("pub const email: TypedColumn<String, Public>"));
1160        assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
1161        
1162        // Verify RelatedTo impls for compile-time relationship checking
1163        assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
1164        assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
1165    }
1166
1167    #[test]
1168    fn test_generate_protected_column() {
1169        let schema_content = r#"
1170table secrets {
1171    id UUID primary_key
1172    token TEXT protected
1173}
1174"#;
1175        let schema = Schema::parse(schema_content).unwrap();
1176        let code = generate_schema_code(&schema);
1177        
1178        // Verify Protected policy
1179        assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
1180    }
1181}
1182