Skip to main content

pgmt/migration/
section_parser.rs

1use anyhow::{Context, Result, anyhow};
2use std::path::Path;
3use std::time::Duration;
4
5/// A section within a migration file
6#[derive(Debug, Clone, PartialEq)]
7pub struct MigrationSection {
8    /// Unique name for this section within the migration
9    pub name: String,
10
11    /// Optional human-readable description for logging
12    pub description: Option<String>,
13
14    /// Transaction mode for this section
15    pub mode: TransactionMode,
16
17    /// Maximum execution time for this section (statement_timeout)
18    pub timeout: Duration,
19
20    /// Maximum time to wait for locks (lock_timeout)
21    pub lock_timeout: Option<Duration>,
22
23    /// Retry configuration (optional)
24    pub retry_config: Option<RetryConfig>,
25
26    /// The SQL to execute for this section
27    pub sql: String,
28
29    /// The raw header line(s) of this section (the `-- pgmt:section` line plus
30    /// any `-- pgmt:` attribute continuation lines), exactly as written.
31    /// Empty for the implicit header-less `default` section. It participates
32    /// in the per-section checksum because `module=`/`remaps=` attributes are
33    /// attribution facts pinned alongside the body.
34    pub raw_header: String,
35
36    /// Owning module (modules feature). `None` = the unmoduled base — also
37    /// what every pre-modules migration parses as.
38    pub module: Option<String>,
39
40    /// Prior owner of this section's objects (re-anchoring baselines and their
41    /// paired acquisition migration sections only): a module name, or
42    /// "(unmoduled)" for the base. `None` for plain and ordinary sections.
43    /// Provenance-cut guarantees at most one source, so this is a single
44    /// value, not a list. Establishment derivation reads it.
45    pub remaps: Option<String>,
46
47    /// Line number where this section starts (for error reporting)
48    pub start_line: usize,
49}
50
51impl MigrationSection {
52    /// The exact content hashed for this section's checksum: the raw header
53    /// line(s) followed by the body SQL. The header carries `module=`/`remaps=`
54    /// attribution, so it is part of the immutable unit. The header-less
55    /// `default` section hashes its body alone.
56    pub fn checksum_content(&self) -> String {
57        if self.raw_header.is_empty() {
58            self.sql.clone()
59        } else {
60            format!("{}\n{}", self.raw_header, self.sql)
61        }
62    }
63}
64
65/// Transaction execution mode for a section
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum TransactionMode {
68    /// Wrap section in BEGIN/COMMIT, rollback on error
69    Transactional,
70
71    /// Execute without transaction wrapper (for CONCURRENTLY, etc.)
72    NonTransactional,
73
74    /// Execute each statement individually with auto-commit
75    Autocommit,
76}
77
78impl TransactionMode {
79    /// The stored/parsed string form of the mode.
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            Self::Transactional => "transactional",
83            Self::NonTransactional => "non-transactional",
84            Self::Autocommit => "autocommit",
85        }
86    }
87}
88
89/// Retry configuration for a section
90#[derive(Debug, Clone, PartialEq)]
91pub struct RetryConfig {
92    /// Number of retry attempts (1 = no retry, execute once)
93    pub attempts: u32,
94
95    /// Base delay between retry attempts
96    pub delay: Duration,
97
98    /// Backoff strategy for retries
99    pub backoff: BackoffStrategy,
100
101    /// Action to take on lock timeout specifically
102    pub on_lock_timeout: LockTimeoutAction,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum BackoffStrategy {
107    /// Use constant delay between retries
108    None,
109
110    /// Exponential backoff: delay * 2^(attempt - 1)
111    Exponential,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum LockTimeoutAction {
116    /// Fail immediately on lock timeout
117    Fail,
118
119    /// Retry according to retry_config
120    Retry,
121}
122
123impl Default for RetryConfig {
124    fn default() -> Self {
125        Self {
126            attempts: 1,
127            delay: Duration::from_secs(0),
128            backoff: BackoffStrategy::None,
129            on_lock_timeout: LockTimeoutAction::Fail,
130        }
131    }
132}
133
134/// Parse a migration file into sections
135pub fn parse_migration_sections(_file_path: &Path, sql: &str) -> Result<Vec<MigrationSection>> {
136    let mut sections = Vec::new();
137    let lines: Vec<&str> = sql.lines().collect();
138    let mut current_section: Option<SectionBuilder> = None;
139    let mut current_sql = String::new();
140
141    for (line_num, line) in lines.iter().enumerate() {
142        if line.trim_start().starts_with("-- pgmt:section") {
143            // Save previous section if exists
144            if let Some(builder) = current_section.take() {
145                sections.push(builder.build(current_sql.clone())?);
146                current_sql.clear();
147            }
148
149            // Start new section
150            let mut builder = SectionBuilder::new(line_num + 1);
151            builder.raw_header.push_str(line);
152
153            // Parse attributes on the same line (e.g., name="test")
154            let rest = line
155                .trim_start()
156                .trim_start_matches("-- pgmt:section")
157                .trim();
158            if !rest.is_empty() {
159                // Create a pseudo-line for parsing
160                let attr_line = format!("-- pgmt:{}", rest);
161                parse_section_attribute(&attr_line, &mut builder)?;
162            }
163
164            current_section = Some(builder);
165        } else if line.trim_start().starts_with("-- pgmt:") {
166            // Parse section attribute
167            if let Some(builder) = current_section.as_mut() {
168                if !builder.raw_header.is_empty() {
169                    builder.raw_header.push('\n');
170                }
171                builder.raw_header.push_str(line);
172                parse_section_attribute(line, builder)?;
173            }
174        } else {
175            // Accumulate SQL
176            current_sql.push_str(line);
177            current_sql.push('\n');
178        }
179    }
180
181    // Save final section
182    if let Some(builder) = current_section {
183        sections.push(builder.build(current_sql)?);
184    }
185
186    // If no sections found, treat entire file as single section
187    if sections.is_empty() {
188        sections.push(MigrationSection {
189            name: "default".to_string(),
190            description: None,
191            mode: TransactionMode::Transactional,
192            timeout: Duration::from_secs(600), // 10 minute default
193            lock_timeout: None,
194            retry_config: None,
195            sql: sql.to_string(),
196            raw_header: String::new(),
197            module: None,
198            remaps: None,
199            start_line: 1,
200        });
201    }
202
203    Ok(sections)
204}
205
206/// Parse section attributes from a line (supports multiple key-value pairs)
207fn parse_section_attribute(line: &str, builder: &mut SectionBuilder) -> Result<()> {
208    let line = line.trim_start().trim_start_matches("-- pgmt:");
209    let line = line.trim();
210
211    // Parse multiple key="value" pairs from the line
212    for (key, value) in parse_key_value_pairs(line)? {
213        match key.as_str() {
214            "name" => builder.name = Some(value),
215            "mode" => builder.mode = Some(parse_transaction_mode(&value)?),
216            "timeout" => builder.timeout = Some(parse_duration(&value)?),
217            "description" => builder.description = Some(value),
218            "retry_attempts" => builder.retry_attempts = Some(value.parse()?),
219            "retry_delay" => builder.retry_delay = Some(parse_duration(&value)?),
220            "retry_backoff" => builder.retry_backoff = Some(parse_backoff(&value)?),
221            "on_lock_timeout" => builder.on_lock_timeout = Some(parse_lock_action(&value)?),
222            "lock_timeout" => builder.lock_timeout = Some(parse_duration(&value)?),
223            "module" => builder.module = Some(value),
224            "remaps" => {
225                // Provenance-cut sections: a remap section is acquired from
226                // exactly ONE prior owner, so `remaps` carries a single source.
227                let sources: Vec<String> = value
228                    .split(',')
229                    .map(|s| s.trim().to_string())
230                    .filter(|s| !s.is_empty())
231                    .collect();
232                if sources.len() > 1 {
233                    return Err(anyhow!(
234                        "section 'remaps' at line {} lists {} sources ('{}'); a provenance-cut \
235                         remap section is acquired from exactly one prior owner. \
236                         Regenerate this baseline with a current pgmt.",
237                        builder.start_line,
238                        sources.len(),
239                        value
240                    ));
241                }
242                builder.remaps = sources.into_iter().next();
243            }
244            _ => {
245                return Err(anyhow!(
246                    "Unknown section attribute '{}' at line {}",
247                    key,
248                    builder.start_line
249                ));
250            }
251        }
252    }
253
254    Ok(())
255}
256
257/// Parse multiple key="value" pairs from a string
258/// Supports: name="table" mode="transactional" timeout="30s"
259fn parse_key_value_pairs(input: &str) -> Result<Vec<(String, String)>> {
260    let mut pairs = Vec::new();
261    let mut chars = input.chars().peekable();
262
263    while chars.peek().is_some() {
264        // Skip whitespace
265        while chars.peek().is_some_and(|c| c.is_whitespace()) {
266            chars.next();
267        }
268
269        if chars.peek().is_none() {
270            break;
271        }
272
273        // Parse key (everything until '=')
274        let mut key = String::new();
275        while let Some(&ch) = chars.peek() {
276            if ch == '=' {
277                chars.next(); // consume '='
278                break;
279            }
280            if ch.is_whitespace() {
281                return Err(anyhow!("Expected '=' after key '{}'", key));
282            }
283            key.push(ch);
284            chars.next();
285        }
286
287        if key.is_empty() {
288            break;
289        }
290
291        // Expect opening quote
292        if chars.next() != Some('"') {
293            return Err(anyhow!("Expected '\"' after '{}='", key));
294        }
295
296        // Parse value (everything until closing quote)
297        let mut value = String::new();
298        let mut found_closing_quote = false;
299        for ch in chars.by_ref() {
300            if ch == '"' {
301                found_closing_quote = true;
302                break;
303            }
304            value.push(ch);
305        }
306
307        if !found_closing_quote {
308            return Err(anyhow!("Missing closing quote for value of '{}'", key));
309        }
310
311        pairs.push((key, value));
312    }
313
314    Ok(pairs)
315}
316
317/// Parse transaction mode from string
318fn parse_transaction_mode(s: &str) -> Result<TransactionMode> {
319    match s.to_lowercase().as_str() {
320        "transactional" => Ok(TransactionMode::Transactional),
321        "non-transactional" => Ok(TransactionMode::NonTransactional),
322        "autocommit" => Ok(TransactionMode::Autocommit),
323        _ => Err(anyhow!("Unknown transaction mode: {}", s)),
324    }
325}
326
327/// Parse backoff strategy from string
328fn parse_backoff(s: &str) -> Result<BackoffStrategy> {
329    match s.to_lowercase().as_str() {
330        "none" => Ok(BackoffStrategy::None),
331        "exponential" => Ok(BackoffStrategy::Exponential),
332        _ => Err(anyhow!("Unknown backoff strategy: {}", s)),
333    }
334}
335
336/// Parse lock timeout action from string
337fn parse_lock_action(s: &str) -> Result<LockTimeoutAction> {
338    match s.to_lowercase().as_str() {
339        "fail" => Ok(LockTimeoutAction::Fail),
340        "retry" => Ok(LockTimeoutAction::Retry),
341        _ => Err(anyhow!("Unknown lock timeout action: {}", s)),
342    }
343}
344
345/// Parse duration strings like "30s", "5m", "2h", "500ms", "1m30s"
346pub fn parse_duration(s: &str) -> Result<Duration> {
347    let s = s.trim();
348    let mut total = Duration::ZERO;
349    let mut num_str = String::new();
350    let mut chars = s.chars().peekable();
351
352    while let Some(ch) = chars.next() {
353        if ch.is_ascii_digit() {
354            num_str.push(ch);
355        } else if ch.is_alphabetic() {
356            // Check for "ms" unit
357            let is_ms = ch == 'm' && chars.peek() == Some(&'s');
358
359            let num: u64 = num_str
360                .parse()
361                .with_context(|| format!("Invalid duration number: {}", num_str))?;
362
363            let unit_duration = if is_ms {
364                chars.next(); // consume 's'
365                Duration::from_millis(num)
366            } else {
367                match ch {
368                    's' => Duration::from_secs(num),
369                    'm' => Duration::from_secs(num * 60),
370                    'h' => Duration::from_secs(num * 3600),
371                    _ => return Err(anyhow!("Unknown duration unit: {}", ch)),
372                }
373            };
374
375            total += unit_duration;
376            num_str.clear();
377        } else if !ch.is_whitespace() {
378            return Err(anyhow!("Invalid character in duration: {}", ch));
379        }
380    }
381
382    if !num_str.is_empty() {
383        return Err(anyhow!("Duration missing unit: {}", num_str));
384    }
385
386    if total.is_zero() {
387        return Err(anyhow!("Invalid duration: {}", s));
388    }
389
390    Ok(total)
391}
392
393/// Helper struct for building sections
394struct SectionBuilder {
395    start_line: usize,
396    raw_header: String,
397    name: Option<String>,
398    description: Option<String>,
399    mode: Option<TransactionMode>,
400    timeout: Option<Duration>,
401    lock_timeout: Option<Duration>,
402    retry_attempts: Option<u32>,
403    retry_delay: Option<Duration>,
404    retry_backoff: Option<BackoffStrategy>,
405    on_lock_timeout: Option<LockTimeoutAction>,
406    module: Option<String>,
407    remaps: Option<String>,
408}
409
410impl SectionBuilder {
411    fn new(start_line: usize) -> Self {
412        Self {
413            start_line,
414            raw_header: String::new(),
415            name: None,
416            description: None,
417            mode: None,
418            timeout: None,
419            lock_timeout: None,
420            retry_attempts: None,
421            retry_delay: None,
422            retry_backoff: None,
423            on_lock_timeout: None,
424            module: None,
425            remaps: None,
426        }
427    }
428
429    fn build(self, sql: String) -> Result<MigrationSection> {
430        let name = self.name.ok_or_else(|| {
431            anyhow!(
432                "Section at line {} missing 'name' attribute",
433                self.start_line
434            )
435        })?;
436
437        // Apply defaults (same as legacy migrations)
438        let mode = self.mode.unwrap_or(TransactionMode::Transactional);
439        let timeout = self.timeout.unwrap_or(Duration::from_secs(600)); // 10 minutes
440
441        // Build retry config if any retry attributes specified
442        let retry_config = if self.retry_attempts.is_some()
443            || self.retry_delay.is_some()
444            || self.retry_backoff.is_some()
445            || self.on_lock_timeout.is_some()
446        {
447            Some(RetryConfig {
448                attempts: self.retry_attempts.unwrap_or(1),
449                delay: self.retry_delay.unwrap_or(Duration::ZERO),
450                backoff: self.retry_backoff.unwrap_or(BackoffStrategy::None),
451                on_lock_timeout: self.on_lock_timeout.unwrap_or(LockTimeoutAction::Fail),
452            })
453        } else {
454            None
455        };
456
457        Ok(MigrationSection {
458            name,
459            description: self.description,
460            module: self.module,
461            remaps: self.remaps,
462            mode,
463            timeout,
464            lock_timeout: self.lock_timeout,
465            retry_config,
466            sql: sql.trim().to_string(),
467            raw_header: self.raw_header,
468            start_line: self.start_line,
469        })
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_parse_duration_seconds() {
479        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
480        assert_eq!(parse_duration("1s").unwrap(), Duration::from_secs(1));
481    }
482
483    #[test]
484    fn test_parse_duration_minutes() {
485        assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
486        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
487    }
488
489    #[test]
490    fn test_parse_duration_hours() {
491        assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
492        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
493    }
494
495    #[test]
496    fn test_parse_duration_milliseconds() {
497        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
498        assert_eq!(parse_duration("100ms").unwrap(), Duration::from_millis(100));
499    }
500
501    #[test]
502    fn test_parse_duration_composite() {
503        assert_eq!(parse_duration("1m30s").unwrap(), Duration::from_secs(90));
504        assert_eq!(parse_duration("1h30m").unwrap(), Duration::from_secs(5400));
505        assert_eq!(
506            parse_duration("2h15m30s").unwrap(),
507            Duration::from_secs(8130)
508        );
509    }
510
511    #[test]
512    fn test_parse_duration_with_spaces() {
513        assert_eq!(parse_duration("30 s").unwrap(), Duration::from_secs(30));
514        assert_eq!(parse_duration("1m 30s").unwrap(), Duration::from_secs(90));
515    }
516
517    #[test]
518    fn test_parse_duration_invalid() {
519        assert!(parse_duration("").is_err());
520        assert!(parse_duration("30").is_err());
521        assert!(parse_duration("abc").is_err());
522        assert!(parse_duration("30x").is_err());
523    }
524
525    #[test]
526    fn test_parse_basic_section() {
527        let sql = r#"
528-- pgmt:section name="test_section"
529-- pgmt:  mode="transactional"
530-- pgmt:  timeout="30s"
531ALTER TABLE users ADD COLUMN test TEXT;
532"#;
533
534        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
535
536        assert_eq!(sections.len(), 1);
537        assert_eq!(sections[0].name, "test_section");
538        assert_eq!(sections[0].mode, TransactionMode::Transactional);
539        assert_eq!(sections[0].timeout, Duration::from_secs(30));
540        assert!(sections[0].sql.contains("ALTER TABLE users"));
541    }
542
543    /// The header participates in `checksum_content`, so header-only attribute
544    /// changes (e.g. `mode=`) are pinned by the section checksum: editing an
545    /// applied section's mode trips the immutability guard.
546    #[test]
547    fn test_header_participates_in_checksum_content() {
548        let body = "CREATE TABLE t1 (id SERIAL PRIMARY KEY);";
549        let transactional = parse_migration_sections(
550            Path::new("m.sql"),
551            &format!("-- pgmt:section name=\"s1\" mode=\"transactional\"\n{body}\n"),
552        )
553        .unwrap();
554        let autocommit = parse_migration_sections(
555            Path::new("m.sql"),
556            &format!("-- pgmt:section name=\"s1\" mode=\"autocommit\"\n{body}\n"),
557        )
558        .unwrap();
559        assert_ne!(
560            transactional[0].checksum_content(),
561            autocommit[0].checksum_content(),
562            "a mode-only header change must change the section checksum"
563        );
564    }
565
566    #[test]
567    fn test_parse_section_with_retry() {
568        let sql = r#"
569-- pgmt:section name="concurrent_index"
570-- pgmt:  mode="non-transactional"
571-- pgmt:  timeout="2s"
572-- pgmt:  retry_attempts="10"
573-- pgmt:  retry_delay="5s"
574-- pgmt:  retry_backoff="exponential"
575CREATE INDEX CONCURRENTLY idx_test ON users(email);
576"#;
577
578        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
579
580        assert_eq!(sections.len(), 1);
581        let section = &sections[0];
582        assert_eq!(section.name, "concurrent_index");
583        assert_eq!(section.mode, TransactionMode::NonTransactional);
584
585        let retry = section.retry_config.as_ref().unwrap();
586        assert_eq!(retry.attempts, 10);
587        assert_eq!(retry.delay, Duration::from_secs(5));
588        assert_eq!(retry.backoff, BackoffStrategy::Exponential);
589    }
590
591    #[test]
592    fn test_parse_multiple_sections() {
593        let sql = r#"
594-- pgmt:section name="section1"
595-- pgmt:  mode="transactional"
596-- pgmt:  timeout="30s"
597ALTER TABLE users ADD COLUMN col1 TEXT;
598
599-- pgmt:section name="section2"
600-- pgmt:  mode="non-transactional"
601-- pgmt:  timeout="2s"
602-- pgmt:  retry_attempts="5"
603CREATE INDEX CONCURRENTLY idx ON users(col1);
604
605-- pgmt:section name="section3"
606-- pgmt:  mode="transactional"
607-- pgmt:  timeout="10s"
608ALTER TABLE users ALTER COLUMN col1 SET NOT NULL;
609"#;
610
611        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
612
613        assert_eq!(sections.len(), 3);
614        assert_eq!(sections[0].name, "section1");
615        assert_eq!(sections[1].name, "section2");
616        assert_eq!(sections[2].name, "section3");
617    }
618
619    #[test]
620    fn test_parse_legacy_migration_without_sections() {
621        let sql = "ALTER TABLE users ADD COLUMN email TEXT;";
622
623        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
624
625        // Legacy migrations get wrapped in a default section
626        assert_eq!(sections.len(), 1);
627        assert_eq!(sections[0].name, "default");
628        assert_eq!(sections[0].mode, TransactionMode::Transactional);
629        assert_eq!(sections[0].timeout, Duration::from_secs(600));
630    }
631
632    #[test]
633    fn test_parse_section_with_description() {
634        let sql = r#"
635-- pgmt:section name="test"
636-- pgmt:  mode="transactional"
637-- pgmt:  timeout="30s"
638-- pgmt:  description="This is a test section"
639ALTER TABLE users ADD COLUMN test TEXT;
640"#;
641
642        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
643        assert_eq!(
644            sections[0].description,
645            Some("This is a test section".to_string())
646        );
647    }
648
649    #[test]
650    fn test_parse_section_with_lock_timeout() {
651        let sql = r#"
652-- pgmt:section name="add_column"
653-- pgmt:  mode="transactional"
654-- pgmt:  timeout="30s"
655-- pgmt:  lock_timeout="2s"
656ALTER TABLE users ADD COLUMN status TEXT;
657"#;
658
659        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
660        assert_eq!(sections[0].lock_timeout, Some(Duration::from_secs(2)));
661    }
662
663    #[test]
664    fn test_parse_section_missing_name() {
665        // Only 'name' is required; mode and timeout have defaults
666        let sql = r#"
667-- pgmt:section
668-- pgmt:  mode="transactional"
669ALTER TABLE users ADD COLUMN test TEXT;
670"#;
671
672        let result = parse_migration_sections(Path::new("test.sql"), sql);
673        assert!(result.is_err());
674        assert!(result.unwrap_err().to_string().contains("name"));
675    }
676
677    #[test]
678    fn test_parse_section_with_defaults() {
679        // Only name specified, mode and timeout use defaults
680        let sql = r#"
681-- pgmt:section name="test"
682ALTER TABLE users ADD COLUMN test TEXT;
683"#;
684
685        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
686        assert_eq!(sections.len(), 1);
687        assert_eq!(sections[0].name, "test");
688        assert_eq!(sections[0].mode, TransactionMode::Transactional);
689        assert_eq!(sections[0].timeout, Duration::from_secs(600)); // 10 minute default
690    }
691
692    #[test]
693    fn test_parse_single_line_section_minimal() {
694        let sql = r#"
695-- pgmt:section name="table" mode="transactional" timeout="30s"
696ALTER TABLE users ADD COLUMN email TEXT;
697"#;
698
699        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
700        assert_eq!(sections.len(), 1);
701        assert_eq!(sections[0].name, "table");
702        assert_eq!(sections[0].mode, TransactionMode::Transactional);
703        assert_eq!(sections[0].timeout, Duration::from_secs(30));
704    }
705
706    #[test]
707    fn test_parse_single_line_section_with_retry() {
708        let sql = r#"
709-- pgmt:section name="index" mode="non-transactional" timeout="2s" retry_attempts="5" retry_delay="1s"
710CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
711"#;
712
713        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
714        assert_eq!(sections.len(), 1);
715        assert_eq!(sections[0].name, "index");
716        assert_eq!(sections[0].mode, TransactionMode::NonTransactional);
717        assert_eq!(sections[0].timeout, Duration::from_secs(2));
718
719        let retry = sections[0].retry_config.as_ref().unwrap();
720        assert_eq!(retry.attempts, 5);
721        assert_eq!(retry.delay, Duration::from_secs(1));
722    }
723
724    #[test]
725    fn test_parse_single_line_with_description() {
726        let sql = r#"
727-- pgmt:section name="my_table" mode="transactional" timeout="10s" description="Create user table"
728CREATE TABLE users (id SERIAL PRIMARY KEY);
729"#;
730
731        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
732        assert_eq!(sections.len(), 1);
733        assert_eq!(sections[0].name, "my_table");
734        assert_eq!(
735            sections[0].description,
736            Some("Create user table".to_string())
737        );
738    }
739
740    #[test]
741    fn test_parse_mixed_single_and_multiline() {
742        let sql = r#"
743-- pgmt:section name="schema" mode="transactional" timeout="30s"
744CREATE TABLE users (id SERIAL);
745
746-- pgmt:section name="index"
747-- pgmt:  mode="non-transactional"
748-- pgmt:  timeout="2s"
749-- pgmt:  retry_attempts="10"
750CREATE INDEX CONCURRENTLY idx ON users(id);
751
752-- pgmt:section name="constraint" mode="transactional" timeout="10s"
753ALTER TABLE users ADD CONSTRAINT check_id CHECK (id > 0);
754"#;
755
756        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
757        assert_eq!(sections.len(), 3);
758
759        // First section (single line)
760        assert_eq!(sections[0].name, "schema");
761        assert_eq!(sections[0].mode, TransactionMode::Transactional);
762
763        // Second section (multi-line)
764        assert_eq!(sections[1].name, "index");
765        assert_eq!(sections[1].mode, TransactionMode::NonTransactional);
766        assert_eq!(sections[1].retry_config.as_ref().unwrap().attempts, 10);
767
768        // Third section (single line)
769        assert_eq!(sections[2].name, "constraint");
770        assert_eq!(sections[2].mode, TransactionMode::Transactional);
771    }
772
773    #[test]
774    fn test_parse_single_line_with_spaces_in_values() {
775        let sql = r#"
776-- pgmt:section name="my complex section" mode="transactional" timeout="5m" description="This has spaces"
777CREATE TABLE test (id INT);
778"#;
779
780        let sections = parse_migration_sections(Path::new("test.sql"), sql).unwrap();
781        assert_eq!(sections.len(), 1);
782        assert_eq!(sections[0].name, "my complex section");
783        assert_eq!(sections[0].description, Some("This has spaces".to_string()));
784    }
785
786    /// `remaps` is accepted on MIGRATION sections too: acquisition sections
787    /// carry the same attribute, same single-source grammar as baseline remap
788    /// sections.
789    #[test]
790    fn test_parse_migration_remap_section() {
791        let sql = r#"
792-- objects moved from module 'a'; runs only on targets without it.
793-- pgmt:section name="b" module="b" remaps="a"
794CREATE TABLE y (id SERIAL PRIMARY KEY);
795
796-- pgmt:section name="default" remaps="a"
797CREATE TABLE x (id SERIAL PRIMARY KEY);
798"#;
799        let sections = parse_migration_sections(Path::new("100_move.sql"), sql).unwrap();
800        assert_eq!(sections.len(), 2);
801        assert_eq!(sections[0].module.as_deref(), Some("b"));
802        assert_eq!(sections[0].remaps.as_deref(), Some("a"));
803        assert_eq!(sections[1].module, None);
804        assert_eq!(sections[1].remaps.as_deref(), Some("a"));
805    }
806
807    /// A `remaps` attribute with multiple comma-separated sources is rejected:
808    /// a provenance-cut remap section has exactly one source, in migrations and
809    /// baselines alike.
810    #[test]
811    fn test_parse_multi_source_remaps_is_error() {
812        let sql = r#"
813-- pgmt:section name="c" module="c" remaps="a,b"
814CREATE TABLE z (id SERIAL PRIMARY KEY);
815"#;
816        let err = parse_migration_sections(Path::new("bad.sql"), sql)
817            .unwrap_err()
818            .to_string();
819        assert!(err.contains("exactly one prior owner"), "{err}");
820    }
821
822    #[test]
823    fn test_parse_key_value_pairs() {
824        use super::parse_key_value_pairs;
825
826        let pairs = parse_key_value_pairs(r#"name="test" mode="transactional""#).unwrap();
827        assert_eq!(pairs.len(), 2);
828        assert_eq!(pairs[0], ("name".to_string(), "test".to_string()));
829        assert_eq!(pairs[1], ("mode".to_string(), "transactional".to_string()));
830    }
831
832    #[test]
833    fn test_parse_key_value_pairs_with_spaces() {
834        use super::parse_key_value_pairs;
835
836        let pairs =
837            parse_key_value_pairs(r#"name="my table" mode="non-transactional" timeout="30s""#)
838                .unwrap();
839        assert_eq!(pairs.len(), 3);
840        assert_eq!(pairs[0], ("name".to_string(), "my table".to_string()));
841        assert_eq!(
842            pairs[1],
843            ("mode".to_string(), "non-transactional".to_string())
844        );
845        assert_eq!(pairs[2], ("timeout".to_string(), "30s".to_string()));
846    }
847
848    #[test]
849    fn test_parse_key_value_pairs_empty() {
850        use super::parse_key_value_pairs;
851
852        let pairs = parse_key_value_pairs("").unwrap();
853        assert_eq!(pairs.len(), 0);
854    }
855
856    #[test]
857    fn test_parse_key_value_pairs_missing_quote() {
858        use super::parse_key_value_pairs;
859
860        let result = parse_key_value_pairs(r#"name=test"#);
861        assert!(result.is_err());
862        assert!(result.unwrap_err().to_string().contains("Expected '\"'"));
863    }
864
865    #[test]
866    fn test_parse_key_value_pairs_unclosed_quote() {
867        use super::parse_key_value_pairs;
868
869        let result = parse_key_value_pairs(r#"name="test"#);
870        assert!(result.is_err());
871        assert!(
872            result
873                .unwrap_err()
874                .to_string()
875                .contains("Missing closing quote")
876        );
877    }
878}