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/// Table schema information with column types
26#[derive(Debug, Clone)]
27pub struct TableSchema {
28    pub name: String,
29    /// Column name -> Column type (e.g., "id" -> "UUID", "name" -> "TEXT")
30    pub columns: HashMap<String, String>,
31}
32
33/// Parsed schema from schema.qail file
34#[derive(Debug, Default)]
35pub struct Schema {
36    pub tables: HashMap<String, TableSchema>,
37}
38
39impl Schema {
40    /// Parse a schema.qail file
41    pub fn parse_file(path: &str) -> Result<Self, String> {
42        let content = fs::read_to_string(path)
43            .map_err(|e| format!("Failed to read schema file '{}': {}", path, e))?;
44        Self::parse(&content)
45    }
46
47    /// Parse schema from string
48    pub fn parse(content: &str) -> Result<Self, String> {
49        let mut schema = Schema::default();
50        let mut current_table: Option<String> = None;
51        let mut current_columns: HashMap<String, String> = HashMap::new();
52
53        for line in content.lines() {
54            let line = line.trim();
55            
56            // Skip comments and empty lines
57            if line.is_empty() || line.starts_with('#') {
58                continue;
59            }
60
61            // Table definition: table name {
62            if line.starts_with("table ") && line.ends_with('{') {
63                // Save previous table if any
64                if let Some(table_name) = current_table.take() {
65                    schema.tables.insert(table_name.clone(), TableSchema {
66                        name: table_name,
67                        columns: std::mem::take(&mut current_columns),
68                    });
69                }
70                
71                // Parse new table name
72                let name = line.trim_start_matches("table ")
73                    .trim_end_matches('{')
74                    .trim()
75                    .to_string();
76                current_table = Some(name);
77            }
78            // End of table definition
79            else if line == "}" {
80                if let Some(table_name) = current_table.take() {
81                    schema.tables.insert(table_name.clone(), TableSchema {
82                        name: table_name,
83                        columns: std::mem::take(&mut current_columns),
84                    });
85                }
86            }
87            // Column definition: column_name TYPE [constraints]
88            // Format from qail pull: "flow_name VARCHAR not_null"
89            else if current_table.is_some() && !line.starts_with('#') && !line.is_empty() {
90                let mut parts = line.split_whitespace();
91                if let Some(col_name) = parts.next() {
92                    // Second word is the type (default to TEXT if missing)
93                    let col_type = parts.next().unwrap_or("TEXT").to_uppercase();
94                    current_columns.insert(col_name.to_string(), col_type);
95                }
96            }
97        }
98
99        Ok(schema)
100    }
101
102    /// Check if table exists
103    pub fn has_table(&self, name: &str) -> bool {
104        self.tables.contains_key(name)
105    }
106
107    /// Get table schema
108    pub fn table(&self, name: &str) -> Option<&TableSchema> {
109        self.tables.get(name)
110    }
111    
112    /// Merge pending migrations into the schema
113    /// Scans migration directory for .sql files and extracts:
114    /// - CREATE TABLE statements
115    /// - ALTER TABLE ADD COLUMN statements
116    pub fn merge_migrations(&mut self, migrations_dir: &str) -> Result<usize, String> {
117        use std::fs;
118        
119        let dir = Path::new(migrations_dir);
120        if !dir.exists() {
121            return Ok(0); // No migrations directory
122        }
123        
124        let mut merged_count = 0;
125        
126        // Walk migration directories (format: migrations/YYYYMMDD_name/up.sql)
127        let entries = fs::read_dir(dir)
128            .map_err(|e| format!("Failed to read migrations dir: {}", e))?;
129        
130        for entry in entries.flatten() {
131            let path = entry.path();
132            
133            // Check for up.sql in subdirectory
134            let up_sql = if path.is_dir() {
135                path.join("up.sql")
136            } else if path.extension().is_some_and(|e| e == "sql") {
137                path.clone()
138            } else {
139                continue;
140            };
141            
142            if up_sql.exists() {
143                let content = fs::read_to_string(&up_sql)
144                    .map_err(|e| format!("Failed to read {}: {}", up_sql.display(), e))?;
145                
146                merged_count += self.parse_sql_migration(&content);
147            }
148        }
149        
150        Ok(merged_count)
151    }
152    
153    /// Parse SQL migration content and extract schema changes
154    fn parse_sql_migration(&mut self, sql: &str) -> usize {
155        let mut changes = 0;
156        
157        // Extract CREATE TABLE statements
158        // Pattern: CREATE TABLE [IF NOT EXISTS] table_name (columns...)
159        for line in sql.lines() {
160            let line_upper = line.trim().to_uppercase();
161            
162            if line_upper.starts_with("CREATE TABLE") {
163                if let Some(table_name) = extract_create_table_name(line) {
164                    // Add table with empty columns (will be filled by column defs)
165                    if !self.tables.contains_key(&table_name) {
166                        self.tables.insert(table_name.clone(), TableSchema {
167                            name: table_name,
168                            columns: HashMap::new(),
169                        });
170                        changes += 1;
171                    }
172                }
173            }
174        }
175        
176        // Extract column definitions from CREATE TABLE blocks
177        let mut current_table: Option<String> = None;
178        let mut in_create_block = false;
179        let mut paren_depth = 0;
180        
181        for line in sql.lines() {
182            let line = line.trim();
183            let line_upper = line.to_uppercase();
184            
185            if line_upper.starts_with("CREATE TABLE") {
186                if let Some(name) = extract_create_table_name(line) {
187                    current_table = Some(name);
188                    in_create_block = true;
189                    paren_depth = 0;
190                }
191            }
192            
193            if in_create_block {
194                paren_depth += line.chars().filter(|c| *c == '(').count();
195                paren_depth = paren_depth.saturating_sub(line.chars().filter(|c| *c == ')').count());
196                
197                // Extract column name (first identifier after opening paren)
198                if let Some(col) = extract_column_from_create(line) {
199                    if let Some(ref table) = current_table {
200                        if let Some(t) = self.tables.get_mut(table) {
201                            if t.columns.insert(col.clone(), "TEXT".to_string()).is_none() {
202                                changes += 1;
203                            }
204                        }
205                    }
206                }
207                
208                if paren_depth == 0 && line.contains(')') {
209                    in_create_block = false;
210                    current_table = None;
211                }
212            }
213            
214            // ALTER TABLE ... ADD COLUMN
215            if line_upper.contains("ALTER TABLE") && line_upper.contains("ADD COLUMN") {
216                if let Some((table, col)) = extract_alter_add_column(line) {
217                    if let Some(t) = self.tables.get_mut(&table) {
218                        if t.columns.insert(col.clone(), "TEXT".to_string()).is_none() {
219                            changes += 1;
220                        }
221                    } else {
222                        // Table might be new from this migration
223                        let mut cols = HashMap::new();
224                        cols.insert(col, "TEXT".to_string());
225                        self.tables.insert(table.clone(), TableSchema {
226                            name: table,
227                            columns: cols,
228                        });
229                        changes += 1;
230                    }
231                }
232            }
233            
234            // ALTER TABLE ... ADD (without COLUMN keyword)
235            if line_upper.contains("ALTER TABLE") && line_upper.contains(" ADD ") && !line_upper.contains("ADD COLUMN") {
236                if let Some((table, col)) = extract_alter_add(line) {
237                    if let Some(t) = self.tables.get_mut(&table) {
238                        if t.columns.insert(col.clone(), "TEXT".to_string()).is_none() {
239                            changes += 1;
240                        }
241                    }
242                }
243            }
244            
245            // DROP TABLE
246            if line_upper.starts_with("DROP TABLE") {
247                if let Some(table_name) = extract_drop_table_name(line) {
248                    if self.tables.remove(&table_name).is_some() {
249                        changes += 1;
250                    }
251                }
252            }
253            
254            // ALTER TABLE ... DROP COLUMN
255            if line_upper.contains("ALTER TABLE") && line_upper.contains("DROP COLUMN") {
256                if let Some((table, col)) = extract_alter_drop_column(line) {
257                    if let Some(t) = self.tables.get_mut(&table) {
258                        if t.columns.remove(&col).is_some() {
259                            changes += 1;
260                        }
261                    }
262                }
263            }
264            
265            // ALTER TABLE ... DROP (without COLUMN keyword - PostgreSQL style)
266            if line_upper.contains("ALTER TABLE") && line_upper.contains(" DROP ") 
267                && !line_upper.contains("DROP COLUMN") 
268                && !line_upper.contains("DROP CONSTRAINT")
269                && !line_upper.contains("DROP INDEX") 
270            {
271                if let Some((table, col)) = extract_alter_drop(line) {
272                    if let Some(t) = self.tables.get_mut(&table) {
273                        if t.columns.remove(&col).is_some() {
274                            changes += 1;
275                        }
276                    }
277                }
278            }
279        }
280        
281        changes
282    }
283}
284
285/// Extract table name from CREATE TABLE statement
286fn extract_create_table_name(line: &str) -> Option<String> {
287    let line_upper = line.to_uppercase();
288    let rest = line_upper.strip_prefix("CREATE TABLE")?;
289    let rest = rest.trim_start();
290    let rest = if rest.starts_with("IF NOT EXISTS") {
291        rest.strip_prefix("IF NOT EXISTS")?.trim_start()
292    } else {
293        rest
294    };
295    
296    // Get table name (first identifier)
297    let name: String = line[line.len() - rest.len()..]
298        .chars()
299        .take_while(|c| c.is_alphanumeric() || *c == '_')
300        .collect();
301    
302    if name.is_empty() { None } else { Some(name.to_lowercase()) }
303}
304
305/// Extract column name from a line inside CREATE TABLE block
306fn extract_column_from_create(line: &str) -> Option<String> {
307    let line = line.trim();
308    
309    // Skip keywords and constraints
310    let line_upper = line.to_uppercase();
311    if line_upper.starts_with("CREATE") || 
312       line_upper.starts_with("PRIMARY") ||
313       line_upper.starts_with("FOREIGN") ||
314       line_upper.starts_with("UNIQUE") ||
315       line_upper.starts_with("CHECK") ||
316       line_upper.starts_with("CONSTRAINT") ||
317       line_upper.starts_with(")") ||
318       line_upper.starts_with("(") ||
319       line.is_empty() {
320        return None;
321    }
322    
323    // First word is column name
324    let name: String = line
325        .trim_start_matches('(')
326        .trim()
327        .chars()
328        .take_while(|c| c.is_alphanumeric() || *c == '_')
329        .collect();
330    
331    if name.is_empty() || name.to_uppercase() == "IF" { None } else { Some(name.to_lowercase()) }
332}
333
334/// Extract table and column from ALTER TABLE ... ADD COLUMN
335fn extract_alter_add_column(line: &str) -> Option<(String, String)> {
336    let line_upper = line.to_uppercase();
337    let alter_pos = line_upper.find("ALTER TABLE")?;
338    let add_pos = line_upper.find("ADD COLUMN")?;
339    
340    // Table name between ALTER TABLE and ADD COLUMN
341    let table_part = &line[alter_pos + 11..add_pos];
342    let table: String = table_part.trim()
343        .chars()
344        .take_while(|c| c.is_alphanumeric() || *c == '_')
345        .collect();
346    
347    // Column name after ADD COLUMN
348    let col_part = &line[add_pos + 10..];
349    let col: String = col_part.trim()
350        .chars()
351        .take_while(|c| c.is_alphanumeric() || *c == '_')
352        .collect();
353    
354    if table.is_empty() || col.is_empty() {
355        None
356    } else {
357        Some((table.to_lowercase(), col.to_lowercase()))
358    }
359}
360
361/// Extract table and column from ALTER TABLE ... ADD (without COLUMN keyword)
362fn extract_alter_add(line: &str) -> Option<(String, String)> {
363    let line_upper = line.to_uppercase();
364    let alter_pos = line_upper.find("ALTER TABLE")?;
365    let add_pos = line_upper.find(" ADD ")?;
366    
367    let table_part = &line[alter_pos + 11..add_pos];
368    let table: String = table_part.trim()
369        .chars()
370        .take_while(|c| c.is_alphanumeric() || *c == '_')
371        .collect();
372    
373    let col_part = &line[add_pos + 5..];
374    let col: String = col_part.trim()
375        .chars()
376        .take_while(|c| c.is_alphanumeric() || *c == '_')
377        .collect();
378    
379    if table.is_empty() || col.is_empty() {
380        None
381    } else {
382        Some((table.to_lowercase(), col.to_lowercase()))
383    }
384}
385
386/// Extract table name from DROP TABLE statement
387fn extract_drop_table_name(line: &str) -> Option<String> {
388    let line_upper = line.to_uppercase();
389    let rest = line_upper.strip_prefix("DROP TABLE")?;
390    let rest = rest.trim_start();
391    let rest = if rest.starts_with("IF EXISTS") {
392        rest.strip_prefix("IF EXISTS")?.trim_start()
393    } else {
394        rest
395    };
396    
397    // Get table name (first identifier)
398    let name: String = line[line.len() - rest.len()..]
399        .chars()
400        .take_while(|c| c.is_alphanumeric() || *c == '_')
401        .collect();
402    
403    if name.is_empty() { None } else { Some(name.to_lowercase()) }
404}
405
406/// Extract table and column from ALTER TABLE ... DROP COLUMN
407fn extract_alter_drop_column(line: &str) -> Option<(String, String)> {
408    let line_upper = line.to_uppercase();
409    let alter_pos = line_upper.find("ALTER TABLE")?;
410    let drop_pos = line_upper.find("DROP COLUMN")?;
411    
412    // Table name between ALTER TABLE and DROP COLUMN
413    let table_part = &line[alter_pos + 11..drop_pos];
414    let table: String = table_part.trim()
415        .chars()
416        .take_while(|c| c.is_alphanumeric() || *c == '_')
417        .collect();
418    
419    // Column name after DROP COLUMN
420    let col_part = &line[drop_pos + 11..];
421    let col: String = col_part.trim()
422        .chars()
423        .take_while(|c| c.is_alphanumeric() || *c == '_')
424        .collect();
425    
426    if table.is_empty() || col.is_empty() {
427        None
428    } else {
429        Some((table.to_lowercase(), col.to_lowercase()))
430    }
431}
432
433/// Extract table and column from ALTER TABLE ... DROP (without COLUMN keyword)
434fn extract_alter_drop(line: &str) -> Option<(String, String)> {
435    let line_upper = line.to_uppercase();
436    let alter_pos = line_upper.find("ALTER TABLE")?;
437    let drop_pos = line_upper.find(" DROP ")?;
438    
439    let table_part = &line[alter_pos + 11..drop_pos];
440    let table: String = table_part.trim()
441        .chars()
442        .take_while(|c| c.is_alphanumeric() || *c == '_')
443        .collect();
444    
445    let col_part = &line[drop_pos + 6..];
446    let col: String = col_part.trim()
447        .chars()
448        .take_while(|c| c.is_alphanumeric() || *c == '_')
449        .collect();
450    
451    if table.is_empty() || col.is_empty() {
452        None
453    } else {
454        Some((table.to_lowercase(), col.to_lowercase()))
455    }
456}
457
458impl TableSchema {
459    /// Check if column exists
460    pub fn has_column(&self, name: &str) -> bool {
461        self.columns.contains_key(name)
462    }
463    
464    /// Get column type
465    pub fn column_type(&self, name: &str) -> Option<&str> {
466        self.columns.get(name).map(|s| s.as_str())
467    }
468}
469
470/// Extracted QAIL usage from source code
471#[derive(Debug)]
472pub struct QailUsage {
473    pub file: String,
474    pub line: usize,
475    pub table: String,
476    pub columns: Vec<String>,
477    pub action: String,
478    pub is_cte_ref: bool,
479}
480
481/// Scan Rust source files for QAIL usage patterns
482pub fn scan_source_files(src_dir: &str) -> Vec<QailUsage> {
483    let mut usages = Vec::new();
484    scan_directory(Path::new(src_dir), &mut usages);
485    usages
486}
487
488fn scan_directory(dir: &Path, usages: &mut Vec<QailUsage>) {
489    if let Ok(entries) = fs::read_dir(dir) {
490        for entry in entries.flatten() {
491            let path = entry.path();
492            if path.is_dir() {
493                scan_directory(&path, usages);
494            } else if path.extension().map_or(false, |e| e == "rs") {
495                if let Ok(content) = fs::read_to_string(&path) {
496                    scan_file(&path.display().to_string(), &content, usages);
497                }
498            }
499        }
500    }
501}
502
503fn scan_file(file: &str, content: &str, usages: &mut Vec<QailUsage>) {
504    // Patterns to match:
505    // Qail::get("table")
506    // Qail::add("table")
507    // Qail::del("table")
508    // Qail::put("table")
509    
510    let patterns = [
511        ("Qail::get(", "GET"),
512        ("Qail::add(", "ADD"),
513        ("Qail::del(", "DEL"),
514        ("Qail::put(", "PUT"),
515    ];
516
517    // First pass: extract all CTE names from .to_cte() patterns
518    // Pattern: .to_cte("cte_name")
519    let mut cte_names: std::collections::HashSet<String> = std::collections::HashSet::new();
520    for line in content.lines() {
521        let line = line.trim();
522        if let Some(pos) = line.find(".to_cte(") {
523            let after = &line[pos + 8..]; // ".to_cte(" is 8 chars
524            if let Some(name) = extract_string_arg(after) {
525                cte_names.insert(name);
526            }
527        }
528    }
529
530    // Second pass: detect Qail usage and mark CTE refs
531    let lines: Vec<&str> = content.lines().collect();
532    let mut i = 0;
533    
534    while i < lines.len() {
535        let line = lines[i].trim();
536        
537        // Check if this line starts a Qail chain
538        for (pattern, action) in &patterns {
539            if let Some(pos) = line.find(pattern) {
540                let start_line = i + 1; // 1-indexed
541                
542                // Extract table name from Qail::get("table")
543                let after = &line[pos + pattern.len()..];
544                if let Some(table) = extract_string_arg(after) {
545                    // Join continuation lines (lines that start with .)
546                    let mut full_chain = line.to_string();
547                    let mut j = i + 1;
548                    while j < lines.len() {
549                        let next = lines[j].trim();
550                        if next.starts_with('.') {
551                            full_chain.push_str(next);
552                            j += 1;
553                        } else if next.is_empty() {
554                            j += 1; // Skip empty lines
555                        } else {
556                            break;
557                        }
558                    }
559                    
560                    // Check if this is a CTE reference
561                    let is_cte_ref = cte_names.contains(&table);
562                    
563                    // Extract column names from the full chain
564                    let columns = extract_columns(&full_chain);
565                    
566                    usages.push(QailUsage {
567                        file: file.to_string(),
568                        line: start_line,
569                        table,
570                        columns,
571                        action: action.to_string(),
572                        is_cte_ref,
573                    });
574                    
575                    // Skip to end of chain
576                    i = j.saturating_sub(1);
577                }
578                break; // Only match one pattern per line
579            }
580        }
581        i += 1;
582    }
583}
584
585fn extract_string_arg(s: &str) -> Option<String> {
586    // Find "string" pattern
587    let s = s.trim();
588    if s.starts_with('"') {
589        let end = s[1..].find('"')?;
590        Some(s[1..end + 1].to_string())
591    } else {
592        None
593    }
594}
595
596fn extract_columns(line: &str) -> Vec<String> {
597    let mut columns = Vec::new();
598    let mut remaining = line;
599    
600    // .column("col")
601    while let Some(pos) = remaining.find(".column(") {
602        let after = &remaining[pos + 8..];
603        if let Some(col) = extract_string_arg(after) {
604            columns.push(col);
605        }
606        remaining = after;
607    }
608    
609    // Reset for next pattern
610    remaining = line;
611    
612    // .filter("col", ...)
613    while let Some(pos) = remaining.find(".filter(") {
614        let after = &remaining[pos + 8..];
615        if let Some(col) = extract_string_arg(after) {
616            // Don't add qualified columns (CTE refs)
617            if !col.contains('.') {
618                columns.push(col);
619            }
620        }
621        remaining = after;
622    }
623    
624    // .eq("col", val), .ne("col", val), .gt, .lt, .gte, .lte
625    for method in [".eq(", ".ne(", ".gt(", ".lt(", ".gte(", ".lte(", ".like(", ".ilike("] {
626        let mut temp = line;
627        while let Some(pos) = temp.find(method) {
628            let after = &temp[pos + method.len()..];
629            if let Some(col) = extract_string_arg(after) {
630                if !col.contains('.') {
631                    columns.push(col);
632                }
633            }
634            temp = after;
635        }
636    }
637    
638    // .order_by("col", ...)
639    let mut remaining = line;
640    while let Some(pos) = remaining.find(".order_by(") {
641        let after = &remaining[pos + 10..];
642        if let Some(col) = extract_string_arg(after) {
643            if !col.contains('.') {
644                columns.push(col);
645            }
646        }
647        remaining = after;
648    }
649    
650    columns
651}
652
653/// Validate QAIL usage against schema using the smart Validator
654/// Provides "Did you mean?" suggestions for typos and type validation
655pub fn validate_against_schema(schema: &Schema, usages: &[QailUsage]) -> Vec<String> {
656    use crate::validator::Validator;
657    
658    // Build Validator from Schema with column types
659    let mut validator = Validator::new();
660    for (table_name, table_schema) in &schema.tables {
661        // Convert HashMap<String, String> to Vec<(&str, &str)>
662        let cols_with_types: Vec<(&str, &str)> = table_schema.columns
663            .iter()
664            .map(|(name, typ)| (name.as_str(), typ.as_str()))
665            .collect();
666        validator.add_table_with_types(table_name, &cols_with_types);
667    }
668    
669    let mut errors = Vec::new();
670
671    for usage in usages {
672        // Skip CTE alias refs - these are defined in code, not in schema
673        if usage.is_cte_ref {
674            continue;
675        }
676        
677        // Use Validator for smart error messages with suggestions
678        match validator.validate_table(&usage.table) {
679            Ok(()) => {
680                // Table exists, check columns
681                for col in &usage.columns {
682                    // Skip qualified columns (CTE refs like cte.column)
683                    if col.contains('.') {
684                        continue;
685                    }
686                    
687                    if let Err(e) = validator.validate_column(&usage.table, col) {
688                        errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
689                    }
690                }
691            }
692            Err(e) => {
693                errors.push(format!("{}:{}: {}", usage.file, usage.line, e));
694            }
695        }
696    }
697
698    errors
699}
700
701/// Main validation entry point for build.rs
702pub fn validate() {
703    let mode = std::env::var("QAIL").unwrap_or_else(|_| {
704        if Path::new("schema.qail").exists() {
705            "schema".to_string()
706        } else {
707            "false".to_string()
708        }
709    });
710
711    match mode.as_str() {
712        "schema" => {
713            println!("cargo:rerun-if-changed=schema.qail");
714            println!("cargo:rerun-if-changed=migrations");
715            println!("cargo:rerun-if-env-changed=QAIL");
716            
717            match Schema::parse_file("schema.qail") {
718                Ok(mut schema) => {
719                    // Merge pending migrations with pulled schema
720                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
721                    if merged > 0 {
722                        println!("cargo:warning=QAIL: Merged {} schema changes from migrations", merged);
723                    }
724                    
725                    let usages = scan_source_files("src/");
726                    let errors = validate_against_schema(&schema, &usages);
727                    
728                    if errors.is_empty() {
729                        println!("cargo:warning=QAIL: Validated {} queries against schema.qail ✓", usages.len());
730                    } else {
731                        for error in &errors {
732                            println!("cargo:warning=QAIL ERROR: {}", error);
733                        }
734                        // Fail the build
735                        panic!("QAIL validation failed with {} errors", errors.len());
736                    }
737                }
738                Err(e) => {
739                    println!("cargo:warning=QAIL: {}", e);
740                }
741            }
742        }
743        "live" => {
744            println!("cargo:rerun-if-env-changed=QAIL");
745            println!("cargo:rerun-if-env-changed=DATABASE_URL");
746            
747            // Get DATABASE_URL for qail pull
748            let db_url = match std::env::var("DATABASE_URL") {
749                Ok(url) => url,
750                Err(_) => {
751                    panic!("QAIL=live requires DATABASE_URL environment variable");
752                }
753            };
754            
755            // Step 1: Run qail pull to update schema.qail
756            println!("cargo:warning=QAIL: Pulling schema from live database...");
757            
758            let pull_result = std::process::Command::new("qail")
759                .args(["pull", &db_url])
760                .output();
761            
762            match pull_result {
763                Ok(output) => {
764                    if !output.status.success() {
765                        let stderr = String::from_utf8_lossy(&output.stderr);
766                        panic!("QAIL: Failed to pull schema: {}", stderr);
767                    }
768                    println!("cargo:warning=QAIL: Schema pulled successfully ✓");
769                }
770                Err(e) => {
771                    // qail CLI not found, try using cargo run
772                    println!("cargo:warning=QAIL: qail CLI not in PATH, trying cargo...");
773                    
774                    let cargo_result = std::process::Command::new("cargo")
775                        .args(["run", "-p", "qail", "--", "pull", &db_url])
776                        .current_dir(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()))
777                        .output();
778                    
779                    match cargo_result {
780                        Ok(output) if output.status.success() => {
781                            println!("cargo:warning=QAIL: Schema pulled via cargo ✓");
782                        }
783                        _ => {
784                            panic!("QAIL: Cannot run qail pull: {}. Install qail CLI or set QAIL=schema", e);
785                        }
786                    }
787                }
788            }
789            
790            // Step 2: Parse the updated schema and validate
791            match Schema::parse_file("schema.qail") {
792                Ok(mut schema) => {
793                    // Merge pending migrations (in case live DB doesn't have them yet)
794                    let merged = schema.merge_migrations("migrations").unwrap_or(0);
795                    if merged > 0 {
796                        println!("cargo:warning=QAIL: Merged {} schema changes from pending migrations", merged);
797                    }
798                    
799                    let usages = scan_source_files("src/");
800                    let errors = validate_against_schema(&schema, &usages);
801                    
802                    if errors.is_empty() {
803                        println!("cargo:warning=QAIL: Validated {} queries against live database ✓", usages.len());
804                    } else {
805                        for error in &errors {
806                            println!("cargo:warning=QAIL ERROR: {}", error);
807                        }
808                        panic!("QAIL validation failed with {} errors", errors.len());
809                    }
810                }
811                Err(e) => {
812                    panic!("QAIL: Failed to parse schema after pull: {}", e);
813                }
814            }
815        }
816        "false" | "off" | "0" => {
817            println!("cargo:rerun-if-env-changed=QAIL");
818            // Silently skip validation
819        }
820        _ => {
821            panic!("QAIL: Unknown mode '{}'. Use: schema, live, or false", mode);
822        }
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    #[test]
831    fn test_parse_schema() {
832        // Format matches qail pull output (space-separated, not colon)
833        let content = r#"
834# Test schema
835
836table users {
837  id UUID primary_key
838  name TEXT not_null
839  email TEXT unique
840}
841
842table posts {
843  id UUID
844  user_id UUID
845  title TEXT
846}
847"#;
848        let schema = Schema::parse(content).unwrap();
849        assert!(schema.has_table("users"));
850        assert!(schema.has_table("posts"));
851        assert!(schema.table("users").unwrap().has_column("id"));
852        assert!(schema.table("users").unwrap().has_column("name"));
853        assert!(!schema.table("users").unwrap().has_column("foo"));
854    }
855
856    #[test]
857    fn test_extract_string_arg() {
858        assert_eq!(extract_string_arg(r#""users")"#), Some("users".to_string()));
859        assert_eq!(extract_string_arg(r#""table_name")"#), Some("table_name".to_string()));
860    }
861
862    #[test]
863    fn test_scan_file() {
864        // Test single-line pattern
865        let content = r#"
866let query = Qail::get("users").column("id").column("name").eq("active", true);
867"#;
868        let mut usages = Vec::new();
869        scan_file("test.rs", content, &mut usages);
870        
871        assert_eq!(usages.len(), 1);
872        assert_eq!(usages[0].table, "users");
873        assert_eq!(usages[0].action, "GET");
874        assert!(usages[0].columns.contains(&"id".to_string()));
875        assert!(usages[0].columns.contains(&"name".to_string()));
876    }
877
878    #[test]
879    fn test_scan_file_multiline() {
880        // Test multi-line chain pattern (common in real code)
881        let content = r#"
882let query = Qail::get("posts")
883    .column("id")
884    .column("title")
885    .column("author")
886    .eq("published", true)
887    .order_by("created_at", Desc);
888"#;
889        let mut usages = Vec::new();
890        scan_file("test.rs", content, &mut usages);
891        
892        assert_eq!(usages.len(), 1);
893        assert_eq!(usages[0].table, "posts");
894        assert_eq!(usages[0].action, "GET");
895        assert!(usages[0].columns.contains(&"id".to_string()));
896        assert!(usages[0].columns.contains(&"title".to_string()));
897        assert!(usages[0].columns.contains(&"author".to_string()));
898    }
899}