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    // IMPORTANT: Must check for word boundaries to avoid matching column names
346    // that happen to start with a keyword (e.g., created_at starts with CREATE,
347    // primary_contact starts with PRIMARY, check_status starts with CHECK, etc.)
348    let line_upper = line.to_uppercase();
349    let starts_with_keyword = |kw: &str| -> bool {
350        line_upper.starts_with(kw)
351            && line_upper[kw.len()..].starts_with(|c: char| c == ' ' || c == '(')
352    };
353    
354    if starts_with_keyword("CREATE") || 
355       starts_with_keyword("PRIMARY") ||
356       starts_with_keyword("FOREIGN") ||
357       starts_with_keyword("UNIQUE") ||
358       starts_with_keyword("CHECK") ||
359       starts_with_keyword("CONSTRAINT") ||
360       line_upper.starts_with(")") ||
361       line_upper.starts_with("(") ||
362       line.is_empty() {
363        return None;
364    }
365    
366    // First word is column name
367    let name: String = line
368        .trim_start_matches('(')
369        .trim()
370        .chars()
371        .take_while(|c| c.is_alphanumeric() || *c == '_')
372        .collect();
373    
374    if name.is_empty() || name.to_uppercase() == "IF" { None } else { Some(name.to_lowercase()) }
375}
376
377/// Extract table and column from ALTER TABLE ... ADD COLUMN
378fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
379    let line_upper = line.to_uppercase();
380    let alter_pos = line_upper.find("ALTER TABLE")?;
381    let add_pos = line_upper.find("ADD COLUMN")?;
382    
383    // Table name between ALTER TABLE and ADD COLUMN
384    let table_part = &line[alter_pos + 11..add_pos];
385    let table: String = table_part.trim()
386        .chars()
387        .take_while(|c| c.is_alphanumeric() || *c == '_')
388        .collect();
389    
390    // Column name after ADD COLUMN
391    let col_part = &line[add_pos + 10..];
392    let col: String = col_part.trim()
393        .chars()
394        .take_while(|c| c.is_alphanumeric() || *c == '_')
395        .collect();
396    
397    if table.is_empty() || col.is_empty() {
398        None
399    } else {
400        Some((table.to_lowercase(), col.to_lowercase()))
401    }
402}
403
404/// Extract table and column from ALTER TABLE ... ADD (without COLUMN keyword)
405fn extract_alter_add(line: &str) -> Option<(String, String)> {
406    let line_upper = line.to_uppercase();
407    let alter_pos = line_upper.find("ALTER TABLE")?;
408    let add_pos = line_upper.find(" ADD ")?;
409    
410    let table_part = &line[alter_pos + 11..add_pos];
411    let table: String = table_part.trim()
412        .chars()
413        .take_while(|c| c.is_alphanumeric() || *c == '_')
414        .collect();
415    
416    let col_part = &line[add_pos + 5..];
417    let col: String = col_part.trim()
418        .chars()
419        .take_while(|c| c.is_alphanumeric() || *c == '_')
420        .collect();
421    
422    if table.is_empty() || col.is_empty() {
423        None
424    } else {
425        Some((table.to_lowercase(), col.to_lowercase()))
426    }
427}
428
429/// Extract table name from DROP TABLE statement
430fn extract_drop_table_name(line: &str) -> Option<String> {
431    let line_upper = line.to_uppercase();
432    let rest = line_upper.strip_prefix("DROP TABLE")?;
433    let rest = rest.trim_start();
434    let rest = if rest.starts_with("IF EXISTS") {
435        rest.strip_prefix("IF EXISTS")?.trim_start()
436    } else {
437        rest
438    };
439    
440    // Get table name (first identifier)
441    let name: String = line[line.len() - rest.len()..]
442        .chars()
443        .take_while(|c| c.is_alphanumeric() || *c == '_')
444        .collect();
445    
446    if name.is_empty() { None } else { Some(name.to_lowercase()) }
447}
448
449/// Extract table and column from ALTER TABLE ... DROP COLUMN
450fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
451    let line_upper = line.to_uppercase();
452    let alter_pos = line_upper.find("ALTER TABLE")?;
453    let drop_pos = line_upper.find("DROP COLUMN")?;
454    
455    // Table name between ALTER TABLE and DROP COLUMN
456    let table_part = &line[alter_pos + 11..drop_pos];
457    let table: String = table_part.trim()
458        .chars()
459        .take_while(|c| c.is_alphanumeric() || *c == '_')
460        .collect();
461    
462    // Column name after DROP COLUMN
463    let col_part = &line[drop_pos + 11..];
464    let col: String = col_part.trim()
465        .chars()
466        .take_while(|c| c.is_alphanumeric() || *c == '_')
467        .collect();
468    
469    if table.is_empty() || col.is_empty() {
470        None
471    } else {
472        Some((table.to_lowercase(), col.to_lowercase()))
473    }
474}
475
476/// Extract table and column from ALTER TABLE ... DROP (without COLUMN keyword)
477fn extract_alter_drop(line: &str) -> Option<(String, String)> {
478    let line_upper = line.to_uppercase();
479    let alter_pos = line_upper.find("ALTER TABLE")?;
480    let drop_pos = line_upper.find(" DROP ")?;
481    
482    let table_part = &line[alter_pos + 11..drop_pos];
483    let table: String = table_part.trim()
484        .chars()
485        .take_while(|c| c.is_alphanumeric() || *c == '_')
486        .collect();
487    
488    let col_part = &line[drop_pos + 6..];
489    let col: String = col_part.trim()
490        .chars()
491        .take_while(|c| c.is_alphanumeric() || *c == '_')
492        .collect();
493    
494    if table.is_empty() || col.is_empty() {
495        None
496    } else {
497        Some((table.to_lowercase(), col.to_lowercase()))
498    }
499}
500
501impl TableSchema {
502    /// Check if column exists
503    pub fn has_column(&self, name: &str) -> bool {
504        self.columns.contains_key(name)
505    }
506    
507    /// Get column type
508    pub fn column_type(&self, name: &str) -> Option<&str> {
509        self.columns.get(name).map(|s| s.as_str())
510    }
511}
512
513/// Extracted QAIL usage from source code
514#[derive(Debug)]
515pub struct QailUsage {
516    pub file: String,
517    pub line: usize,
518    pub table: String,
519    pub columns: Vec<String>,
520    pub action: String,
521    pub is_cte_ref: bool,
522}
523
524/// Scan Rust source files for QAIL usage patterns
525pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
526    let mut usages = Vec::new();
527    scan_directory(Path::new(src_dir), &mut usages);
528    usages
529}
530
531fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
532    if let Ok(entries) = fs::read_dir(dir) {
533        for entry in entries.flatten() {
534            let path = entry.path();
535            if path.is_dir() {
536                scan_directory(&path, usages);
537            } else if path.extension().is_some_and(|e| e == "rs")
538                && let Ok(content) = fs::read_to_string(&path)
539            {
540                scan_file(&path.display().to_string(), &content, usages);
541            }
542        }
543    }
544}
545
546fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
547    // Patterns to match:
548    // Qail::get("table")
549    // Qail::add("table")
550    // Qail::del("table")
551    // Qail::put("table")
552    
553    let patterns = [
554        ("Qail::get(", "GET"),
555        ("Qail::add(", "ADD"),
556        ("Qail::del(", "DEL"),
557        ("Qail::put(", "PUT"),
558    ];
559
560    // First pass: extract all CTE names from .to_cte() patterns
561    // Pattern: .to_cte("cte_name")
562    let mut cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
563    for line in content.lines() {
564        let line = line.trim();
565        if let Some(pos) = line.find(".to_cte(") {
566            let after = &line[pos + 8..]; // ".to_cte(" is 8 chars
567            if let Some(name) = extract_string_arg(after) {
568                cte_names.insert(name);
569            }
570        }
571    }
572
573    // Second pass: detect Qail usage and mark CTE refs
574    let lines: Vec<&str> = content.lines().collect();
575    let mut i = 0;
576    
577    while i < lines.len() {
578        let line = lines[i].trim();
579        
580        // Check if this line starts a Qail chain
581        for (pattern, action) in &patterns {
582            if let Some(pos) = line.find(pattern) {
583                let start_line = i + 1; // 1-indexed
584                
585                // Extract table name from Qail::get("table")
586                let after = &line[pos + pattern.len()..];
587                if let Some(table) = extract_string_arg(after) {
588                    // Join continuation lines (lines that start with .)
589                    let mut full_chain = line.to_string();
590                    let mut j = i + 1;
591                    while j < lines.len() {
592                        let next = lines[j].trim();
593                        if next.starts_with('.') {
594                            full_chain.push_str(next);
595                            j += 1;
596                        } else if next.is_empty() {
597                            j += 1; // Skip empty lines
598                        } else {
599                            break;
600                        }
601                    }
602                    
603                    // Check if this is a CTE reference
604                    let is_cte_ref = cte_names.contains(&table);
605                    
606                    // Extract column names from the full chain
607                    let columns = extract_columns(&full_chain);
608                    
609                    usages.push(QailUsage {
610                        file: file.to_string(),
611                        line: start_line,
612                        table,
613                        columns,
614                        action: action.to_string(),
615                        is_cte_ref,
616                    });
617                    
618                    // Skip to end of chain
619                    i = j.saturating_sub(1);
620                }
621                break; // Only match one pattern per line
622            }
623        }
624        i += 1;
625    }
626}
627
628fn extract_string_arg(s: &str) -> Option<String> {
629    // Find "string" pattern
630    let s = s.trim();
631    if let Some(stripped) = s.strip_prefix('"') {
632        let end = stripped.find('"')?;
633        Some(stripped[..end].to_string())
634    } else {
635        None
636    }
637}
638
639fn extract_columns(line: &str) -> Vec<String> {
640    let mut columns = Vec::new();
641    let mut remaining = line;
642    
643    // .column("col")
644    while let Some(pos) = remaining.find(".column(") {
645        let after = &remaining[pos + 8..];
646        if let Some(col) = extract_string_arg(after) {
647            columns.push(col);
648        }
649        remaining = after;
650    }
651    
652    // Reset for next pattern
653    remaining = line;
654    
655    // .filter("col", ...)
656    while let Some(pos) = remaining.find(".filter(") {
657        let after = &remaining[pos + 8..];
658        if let Some(col) = extract_string_arg(after)
659            && !col.contains('.') {
660            columns.push(col);
661        }
662        remaining = after;
663    }
664    
665    // .eq("col", val), .ne("col", val), .gt, .lt, .gte, .lte
666    for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
667        let mut temp = line;
668        while let Some(pos) = temp.find(method) {
669            let after = &temp[pos + method.len()..];
670            if let Some(col) = extract_string_arg(after)
671                && !col.contains('.') {
672                columns.push(col);
673            }
674            temp = after;
675        }
676    }
677    
678    // .order_by("col", ...)
679    let mut remaining = line;
680    while let Some(pos) = remaining.find(".order_by(") {
681        let after = &remaining[pos + 10..];
682        if let Some(col) = extract_string_arg(after)
683            && !col.contains('.') {
684            columns.push(col);
685        }
686        remaining = after;
687    }
688    
689    columns
690}
691
692/// Validate QAIL usage against schema using the smart Validator
693/// Provides "Did you mean?" suggestions for typos and type validation
694pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
695    use crate::validator::Validator;
696    
697    // Build Validator from Schema with column types
698    let mut validator = Validator::new();
699    for (table_name, table_schema) in &schema.tables {
700        // Convert HashMap<String, String> to Vec<(&str, &str)>
701        let cols_with_types: Vec<(&str, &str)> = table_schema.columns
702            .iter()
703            .map(|(name, typ)| (name.as_str(), typ.as_str()))
704            .collect();
705        validator.add_table_with_types(table_name, &cols_with_types);
706    }
707    
708    let mut errors = Vec::new();
709
710    for usage in usages {
711        // Skip CTE alias refs - these are defined in code, not in schema
712        if usage.is_cte_ref {
713            continue;
714        }
715        
716        // Use Validator for smart error messages with suggestions
717        match validator.validate_table(&usage.table) {
718            Ok(()) => {
719                // Table exists, check columns
720                for col in &usage.columns {
721                    // Skip qualified columns (CTE refs like cte.column)
722                    if col.contains('.') {
723                        continue;
724                    }
725                    
726                    if let Err(e) = validator.validate_column(&usage.table, col) {
727                        errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
728                    }
729                }
730            }
731            Err(e) => {
732                errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
733            }
734        }
735    }
736
737    errors
738}
739
740/// Main validation entry point for build.rs
741pub fn validate() {
742    let mode = std::env::var("QAIL").unwrap_or_else(|_| {
743        if Path::new("schema.qail").exists() {
744            "schema".to_string()
745        } else {
746            "false".to_string()
747        }
748    });
749
750    match mode.as_str() {
751        "schema" => {
752            println!("cargo:rerun-if-changed=schema.qail");
753            println!("cargo:rerun-if-changed=migrations");
754            println!("cargo:rerun-if-env-changed=QAIL");
755            
756            match Schema::parse_file("schema.qail") {
757                Ok(mut schema) => {
758                    // Merge pending migrations with pulled schema
759                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
760                    if merged > 0 {
761                        println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
762                    }
763                    
764                    let usages = scan_source_files("src/");
765                    let errors = validate_against_schema(&schema, &usages);
766                    
767                    if errors.is_empty() {
768                        println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
769                    } else {
770                        for error in &errors {
771                            println!("cargo:warning=QAIL ERROR: {}", error);
772                        }
773                        // Fail the build
774                        panic!("QAIL validation failed with {} errors", errors.len());
775                    }
776                }
777                Err(e) => {
778                    println!("cargo:warning=QAIL: {}", e);
779                }
780            }
781        }
782        "live" => {
783            println!("cargo:rerun-if-env-changed=QAIL");
784            println!("cargo:rerun-if-env-changed=DATABASE_URL");
785            
786            // Get DATABASE_URL for qail pull
787            let db_url = match std::env::var("DATABASE_URL") {
788                Ok(url) => url,
789                Err(_) => {
790                    panic!("QAIL=live requires DATABASE_URL environment variable");
791                }
792            };
793            
794            // Step 1: Run qail pull to update schema.qail
795            println!("cargo:warning=QAIL: Pulling schema from live database...");
796            
797            let pull_result = std::process::Command::new("qail")
798                .args(["pull", &db_url])
799                .output();
800            
801            match pull_result {
802                Ok(output) => {
803                    if !output.status.success() {
804                        let stderr = String::from_utf8_lossy(&output.stderr);
805                        panic!("QAIL: Failed to pull schema: {}", stderr);
806                    }
807                    println!("cargo:warning=QAIL: Schema pulled successfully ✓");
808                }
809                Err(e) => {
810                    // qail CLI not found, try using cargo run
811                    println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
812                    
813                    let cargo_result = std::process::Command::new("cargo")
814                        .args(["run", "-p", "qail", "--", "pull", &db_url])
815                        .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
816                        .output();
817                    
818                    match cargo_result {
819                        Ok(output) if output.status.success() => {
820                            println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
821                        }
822                        _ => {
823                            panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
824                        }
825                    }
826                }
827            }
828            
829            // Step 2: Parse the updated schema and validate
830            match Schema::parse_file("schema.qail") {
831                Ok(mut schema) => {
832                    // Merge pending migrations (in case live DB doesn't have them yet)
833                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
834                    if merged > 0 {
835                        println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
836                    }
837                    
838                    let usages = scan_source_files("src/");
839                    let errors = validate_against_schema(&schema, &usages);
840                    
841                    if errors.is_empty() {
842                        println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
843                    } else {
844                        for error in &errors {
845                            println!("cargo:warning=QAIL ERROR: {}", error);
846                        }
847                        panic!("QAIL validation failed with {} errors", errors.len());
848                    }
849                }
850                Err(e) => {
851                    panic!("QAIL: Failed to parse schema after pull: {}", e);
852                }
853            }
854        }
855        "false" | "off" | "0" => {
856            println!("cargo:rerun-if-env-changed=QAIL");
857            // Silently skip validation
858        }
859        _ => {
860            panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
861        }
862    }
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    #[test]
870    fn test_parse_schema() {
871        // Format matches qail pull output (space-separated, not colon)
872        let content = r#"
873# Test schema
874
875table users {
876  id UUID primary_key
877  name TEXT not_null
878  email TEXT unique
879}
880
881table posts {
882  id UUID
883  user_id UUID
884  title TEXT
885}
886"#;
887        let schema = Schema::parse(content).unwrap();
888        assert!(schema.has_table("users"));
889        assert!(schema.has_table("posts"));
890        assert!(schema.table("users").unwrap().has_column("id"));
891        assert!(schema.table("users").unwrap().has_column("name"));
892        assert!(!schema.table("users").unwrap().has_column("foo"));
893    }
894
895    #[test]
896    fn test_extract_string_arg() {
897        assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
898        assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
899    }
900
901    #[test]
902    fn test_scan_file() {
903        // Test single-line pattern
904        let content = r#"
905let query = Qail::get("users").column("id").column("name").eq("active", true);
906"#;
907        let mut usages = Vec::new();
908        scan_file("test.rs", content, &mut usages);
909        
910        assert_eq!(usages.len(), 1);
911        assert_eq!(usages[0].table, "users");
912        assert_eq!(usages[0].action, "GET");
913        assert!(usages[0].columns.contains(&"id".to_string()));
914        assert!(usages[0].columns.contains(&"name".to_string()));
915    }
916
917    #[test]
918    fn test_scan_file_multiline() {
919        // Test multi-line chain pattern (common in real code)
920        let content = r#"
921let query = Qail::get("posts")
922    .column("id")
923    .column("title")
924    .column("author")
925    .eq("published", true)
926    .order_by("created_at", Desc);
927"#;
928        let mut usages = Vec::new();
929        scan_file("test.rs", content, &mut usages);
930        
931        assert_eq!(usages.len(), 1);
932        assert_eq!(usages[0].table, "posts");
933        assert_eq!(usages[0].action, "GET");
934        assert!(usages[0].columns.contains(&"id".to_string()));
935        assert!(usages[0].columns.contains(&"title".to_string()));
936        assert!(usages[0].columns.contains(&"author".to_string()));
937    }
938}
939
940// =============================================================================
941// Typed Schema Codegen
942// =============================================================================
943
944/// Map QAIL types to Rust types for TypedColumn<T>
945fn qail_type_to_rust(qail_type: &str) -> &'static str {
946    match qail_type.to_uppercase().as_str() {
947        "UUID" => "uuid::Uuid",
948        "TEXT" | "VARCHAR" | "CHAR" | "STRING" => "String",
949        "INT" | "INTEGER" | "INT4" | "SERIAL" => "i32",
950        "BIGINT" | "INT8" | "BIGSERIAL" => "i64",
951        "SMALLINT" | "INT2" => "i16",
952        "FLOAT" | "FLOAT4" | "REAL" => "f32",
953        "DOUBLE" | "FLOAT8" | "DOUBLE PRECISION" => "f64",
954        "DECIMAL" | "NUMERIC" => "rust_decimal::Decimal",
955        "BOOL" | "BOOLEAN" => "bool",
956        "TIMESTAMP" | "TIMESTAMPTZ" => "chrono::DateTime<chrono::Utc>",
957        "DATE" => "chrono::NaiveDate",
958        "TIME" | "TIMETZ" => "chrono::NaiveTime",
959        "JSON" | "JSONB" => "serde_json::Value",
960        "BYTEA" | "BLOB" => "Vec<u8>",
961        _ => "String", // Default to String for unknown types
962    }
963}
964
965/// Convert table/column names to valid Rust identifiers
966fn to_rust_ident(name: &str) -> String {
967    // Handle Rust keywords
968    let name = match name {
969        "type" => "r#type",
970        "match" => "r#match",
971        "ref" => "r#ref",
972        "self" => "r#self",
973        "mod" => "r#mod",
974        "use" => "r#use",
975        _ => name,
976    };
977    name.to_string()
978}
979
980/// Convert table name to PascalCase struct name
981fn to_struct_name(name: &str) -> String {
982    name.chars()
983        .next()
984        .map(|c| c.to_uppercase().collect::<String>() + &name[1..])
985        .unwrap_or_default()
986}
987
988/// Generate typed Rust module from schema.
989/// 
990/// # Usage in consumer's build.rs:
991/// ```ignore
992/// fn main() {
993///     let out_dir = std::env::var("OUT_DIR").unwrap();
994///     qail_core::build::generate_typed_schema("schema.qail", &format!("{}/schema.rs", out_dir)).unwrap();
995///     println!("cargo:rerun-if-changed=schema.qail");
996/// }
997/// ```
998/// 
999/// Then in the consumer's lib.rs:
1000/// ```ignore
1001/// include!(concat!(env!("OUT_DIR"), "/schema.rs"));
1002/// ```
1003pub fn generate_typed_schema(schema_path: &str, output_path: &str) -> Result<(), String> {
1004    let schema = Schema::parse_file(schema_path)?;
1005    let code = generate_schema_code(&schema);
1006    
1007    fs::write(output_path, code)
1008        .map_err(|e| format!("Failed to write schema module to '{}': {}", output_path, e))?;
1009    
1010    Ok(())
1011}
1012
1013/// Generate typed Rust code from schema (does not write to file)
1014pub fn generate_schema_code(schema: &Schema) -> String {
1015    let mut code = String::new();
1016    
1017    // Header
1018    code.push_str("//! Auto-generated typed schema from schema.qail\n");
1019    code.push_str("//! Do not edit manually - regenerate with `cargo build`\n\n");
1020    code.push_str("#![allow(dead_code, non_upper_case_globals)]\n\n");
1021    code.push_str("use qail_core::typed::{Table, TypedColumn, RelatedTo, Public, Protected};\n\n");
1022    
1023    // Sort tables for deterministic output
1024    let mut tables: Vec<_> = schema.tables.values().collect();
1025    tables.sort_by(|a, b| a.name.cmp(&b.name));
1026    
1027    for table in &tables {
1028        let mod_name = to_rust_ident(&table.name);
1029        let struct_name = to_struct_name(&table.name);
1030        
1031        code.push_str(&format!("/// Typed schema for `{}` table\n", table.name));
1032        code.push_str(&format!("pub mod {} {{\n", mod_name));
1033        code.push_str("    use super::*;\n\n");
1034        
1035        // Table struct implementing Table trait
1036        code.push_str(&format!("    /// Table marker for `{}`\n", table.name));
1037        code.push_str("    #[derive(Debug, Clone, Copy)]\n");
1038        code.push_str(&format!("    pub struct {};\n\n", struct_name));
1039        
1040        code.push_str(&format!("    impl Table for {} {{\n", struct_name));
1041        code.push_str(&format!("        fn table_name() -> &'static str {{ \"{}\" }}\n", table.name));
1042        code.push_str("    }\n\n");
1043        
1044        code.push_str(&format!("    impl From<{}> for String {{\n", struct_name));
1045        code.push_str(&format!("        fn from(_: {}) -> String {{ \"{}\".to_string() }}\n", struct_name, table.name));
1046        code.push_str("    }\n\n");
1047
1048        code.push_str(&format!("    impl AsRef<str> for {} {{\n", struct_name));
1049        code.push_str(&format!("        fn as_ref(&self) -> &str {{ \"{}\" }}\n", table.name));
1050        code.push_str("    }\n\n");
1051        
1052        // Table constant for convenience
1053        code.push_str(&format!("    /// The `{}` table\n", table.name));
1054        code.push_str(&format!("    pub const table: {} = {};\n\n", struct_name, struct_name));
1055        
1056        // Sort columns for deterministic output
1057        let mut columns: Vec<_> = table.columns.iter().collect();
1058        columns.sort_by(|a, b| a.0.cmp(b.0));
1059        
1060        // Column constants
1061        for (col_name, col_type) in columns {
1062            let rust_type = qail_type_to_rust(col_type);
1063            let col_ident = to_rust_ident(col_name);
1064            let policy = table.policies.get(col_name).map(|s| s.as_str()).unwrap_or("Public");
1065            let rust_policy = if policy == "Protected" { "Protected" } else { "Public" };
1066            
1067            code.push_str(&format!("    /// Column `{}.{}` ({}) - {}\n", table.name, col_name, col_type, policy));
1068            code.push_str(&format!(
1069                "    pub const {}: TypedColumn<{}, {}> = TypedColumn::new(\"{}\", \"{}\");\n",
1070                col_ident, rust_type, rust_policy, table.name, col_name
1071            ));
1072        }
1073        
1074        code.push_str("}\n\n");
1075    }
1076    
1077    // ==========================================================================
1078    // Generate RelatedTo impls for compile-time relationship checking
1079    // ==========================================================================
1080    
1081    code.push_str("// =============================================================================\n");
1082    code.push_str("// Compile-Time Relationship Safety (RelatedTo impls)\n");
1083    code.push_str("// =============================================================================\n\n");
1084    
1085    for table in &tables {
1086        for fk in &table.foreign_keys {
1087            // table.column refs ref_table.ref_column
1088            // This means: table is related TO ref_table (forward)
1089            // AND: ref_table is related FROM table (reverse - parent has many children)
1090            
1091            let from_mod = to_rust_ident(&table.name);
1092            let from_struct = to_struct_name(&table.name);
1093            let to_mod = to_rust_ident(&fk.ref_table);
1094            let to_struct = to_struct_name(&fk.ref_table);
1095            
1096            // Forward: From table (child) -> Referenced table (parent)
1097            // Example: posts -> users (posts.user_id -> users.id)
1098            code.push_str(&format!(
1099                "/// {} has a foreign key to {} via {}.{}\n",
1100                table.name, fk.ref_table, table.name, fk.column
1101            ));
1102            code.push_str(&format!(
1103                "impl RelatedTo<{}::{}> for {}::{} {{\n",
1104                to_mod, to_struct, from_mod, from_struct
1105            ));
1106            code.push_str(&format!(
1107                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1108                fk.column, fk.ref_column
1109            ));
1110            code.push_str("}\n\n");
1111            
1112            // Reverse: Referenced table (parent) -> From table (child)
1113            // Example: users -> posts (users.id -> posts.user_id)
1114            // This allows: Qail::get(users::table).join_related(posts::table)
1115            code.push_str(&format!(
1116                "/// {} is referenced by {} via {}.{}\n",
1117                fk.ref_table, table.name, table.name, fk.column
1118            ));
1119            code.push_str(&format!(
1120                "impl RelatedTo<{}::{}> for {}::{} {{\n",
1121                from_mod, from_struct, to_mod, to_struct
1122            ));
1123            code.push_str(&format!(
1124                "    fn join_columns() -> (&'static str, &'static str) {{ (\"{}\", \"{}\") }}\n",
1125                fk.ref_column, fk.column
1126            ));
1127            code.push_str("}\n\n");
1128        }
1129    }
1130    
1131    code
1132}
1133
1134#[cfg(test)]
1135mod codegen_tests {
1136    use super::*;
1137    
1138    #[test]
1139    fn test_generate_schema_code() {
1140        let schema_content = r#"
1141table users {
1142    id UUID primary_key
1143    email TEXT not_null
1144    age INT
1145}
1146
1147table posts {
1148    id UUID primary_key
1149    user_id UUID ref:users.id
1150    title TEXT
1151}
1152"#;
1153        
1154        let schema = Schema::parse(schema_content).unwrap();
1155        let code = generate_schema_code(&schema);
1156        
1157        // Verify module structure
1158        assert!(code.contains("pub mod users {"));
1159        assert!(code.contains("pub mod posts {"));
1160        
1161        // Verify table structs
1162        assert!(code.contains("pub struct Users;"));
1163        assert!(code.contains("pub struct Posts;"));
1164        
1165        // Verify columns
1166        assert!(code.contains("pub const id: TypedColumn<uuid::Uuid, Public>"));
1167        assert!(code.contains("pub const email: TypedColumn<String, Public>"));
1168        assert!(code.contains("pub const age: TypedColumn<i32, Public>"));
1169        
1170        // Verify RelatedTo impls for compile-time relationship checking
1171        assert!(code.contains("impl RelatedTo<users::Users> for posts::Posts"));
1172        assert!(code.contains("impl RelatedTo<posts::Posts> for users::Users"));
1173    }
1174
1175    #[test]
1176    fn test_generate_protected_column() {
1177        let schema_content = r#"
1178table secrets {
1179    id UUID primary_key
1180    token TEXT protected
1181}
1182"#;
1183        let schema = Schema::parse(schema_content).unwrap();
1184        let code = generate_schema_code(&schema);
1185        
1186        // Verify Protected policy
1187        assert!(code.contains("pub const token: TypedColumn<String, Protected>"));
1188    }
1189}
1190
1191
1192
1193#[cfg(test)]
1194mod migration_parser_tests {
1195    use super::*;
1196
1197    #[test]
1198    fn test_agent_contracts_migration_parses_all_columns() {
1199        let sql = r#"
1200CREATE TABLE agent_contracts (
1201    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1202    agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
1203    operator_id UUID NOT NULL REFERENCES operators(id) ON DELETE CASCADE,
1204    pricing_model VARCHAR(20) NOT NULL CHECK (pricing_model IN ('commission', 'static_markup', 'net_rate')),
1205    commission_percent DECIMAL(5,2),
1206    static_markup DECIMAL(10,2),
1207    is_active BOOLEAN DEFAULT true,
1208    valid_from DATE,
1209    valid_until DATE,
1210    approved_by UUID REFERENCES users(id),
1211    created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1212    updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
1213    UNIQUE(agent_id, operator_id)
1214);
1215"#;
1216
1217        let mut schema = Schema::default();
1218        schema.parse_sql_migration(sql);
1219        
1220        let table = schema.tables.get("agent_contracts")
1221            .expect("agent_contracts table should exist");
1222        
1223        for col in &["id", "agent_id", "operator_id", "pricing_model",
1224                      "commission_percent", "static_markup", "is_active",
1225                      "valid_from", "valid_until", "approved_by",
1226                      "created_at", "updated_at"] {
1227            assert!(
1228                table.columns.contains_key(*col),
1229                "Missing column: '{}'. Found: {:?}",
1230                col, table.columns.keys().collect::<Vec<_>>()
1231            );
1232        }
1233    }
1234
1235    /// Regression test: column names that START with SQL keywords must parse correctly.
1236    /// e.g., created_at starts with CREATE, primary_contact starts with PRIMARY, etc.
1237    #[test]
1238    fn test_keyword_prefixed_column_names_are_not_skipped() {
1239        let sql = r#"
1240CREATE TABLE edge_cases (
1241    id UUID PRIMARY KEY,
1242    created_at TIMESTAMPTZ NOT NULL,
1243    created_by UUID,
1244    primary_contact VARCHAR(255),
1245    check_status VARCHAR(20),
1246    unique_code VARCHAR(50),
1247    foreign_ref UUID,
1248    constraint_name VARCHAR(100),
1249    PRIMARY KEY (id),
1250    CHECK (check_status IN ('pending', 'active')),
1251    UNIQUE (unique_code),
1252    CONSTRAINT fk_ref FOREIGN KEY (foreign_ref) REFERENCES other(id)
1253);
1254"#;
1255
1256        let mut schema = Schema::default();
1257        schema.parse_sql_migration(sql);
1258        
1259        let table = schema.tables.get("edge_cases")
1260            .expect("edge_cases table should exist");
1261        
1262        // These column names start with SQL keywords — all must be found
1263        for col in &["created_at", "created_by", "primary_contact",
1264                      "check_status", "unique_code", "foreign_ref",
1265                      "constraint_name"] {
1266            assert!(
1267                table.columns.contains_key(*col),
1268                "Column '{}' should NOT be skipped just because it starts with a SQL keyword. Found: {:?}",
1269                col, table.columns.keys().collect::<Vec<_>>()
1270            );
1271        }
1272        
1273        // These are constraint keywords, not columns — must NOT appear
1274        // (PRIMARY KEY, CHECK, UNIQUE, CONSTRAINT lines should be skipped)
1275        assert!(!table.columns.contains_key("primary"),
1276            "Constraint keyword 'PRIMARY' should not be treated as a column");
1277    }
1278}