1use anyhow::{Context, Result, anyhow};
2use std::path::Path;
3use std::time::Duration;
4
5#[derive(Debug, Clone, PartialEq)]
7pub struct MigrationSection {
8 pub name: String,
10
11 pub description: Option<String>,
13
14 pub mode: TransactionMode,
16
17 pub timeout: Duration,
19
20 pub lock_timeout: Option<Duration>,
22
23 pub retry_config: Option<RetryConfig>,
25
26 pub sql: String,
28
29 pub raw_header: String,
35
36 pub module: Option<String>,
39
40 pub remaps: Option<String>,
46
47 pub start_line: usize,
49}
50
51impl MigrationSection {
52 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum TransactionMode {
68 Transactional,
70
71 NonTransactional,
73
74 Autocommit,
76}
77
78impl TransactionMode {
79 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#[derive(Debug, Clone, PartialEq)]
91pub struct RetryConfig {
92 pub attempts: u32,
94
95 pub delay: Duration,
97
98 pub backoff: BackoffStrategy,
100
101 pub on_lock_timeout: LockTimeoutAction,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum BackoffStrategy {
107 None,
109
110 Exponential,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum LockTimeoutAction {
116 Fail,
118
119 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
134pub 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 if let Some(builder) = current_section.take() {
145 sections.push(builder.build(current_sql.clone())?);
146 current_sql.clear();
147 }
148
149 let mut builder = SectionBuilder::new(line_num + 1);
151 builder.raw_header.push_str(line);
152
153 let rest = line
155 .trim_start()
156 .trim_start_matches("-- pgmt:section")
157 .trim();
158 if !rest.is_empty() {
159 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 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 current_sql.push_str(line);
177 current_sql.push('\n');
178 }
179 }
180
181 if let Some(builder) = current_section {
183 sections.push(builder.build(current_sql)?);
184 }
185
186 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), 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
206fn 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 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 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
257fn 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 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 let mut key = String::new();
275 while let Some(&ch) = chars.peek() {
276 if ch == '=' {
277 chars.next(); 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 if chars.next() != Some('"') {
293 return Err(anyhow!("Expected '\"' after '{}='", key));
294 }
295
296 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
317fn 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
327fn 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
336fn 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
345pub 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 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(); 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
393struct 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 let mode = self.mode.unwrap_or(TransactionMode::Transactional);
439 let timeout = self.timeout.unwrap_or(Duration::from_secs(600)); 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 #[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 = §ions[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 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 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 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)); }
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 assert_eq!(sections[0].name, "schema");
761 assert_eq!(sections[0].mode, TransactionMode::Transactional);
762
763 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 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 #[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 #[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}