db_migrate/
utils.rs

1use chrono::{DateTime, Utc};
2use sha2::{Digest, Sha256};
3use std::path::Path;
4use tokio::fs;
5
6/// Generate a timestamp-based migration version
7pub fn generate_migration_version() -> String {
8    let now = Utc::now();
9    now.format("%Y%m%d_%H%M%S").to_string()
10}
11
12/// Calculate SHA256 checksum of a string
13pub fn calculate_checksum(content: &str) -> String {
14    let mut hasher = Sha256::new();
15    hasher.update(content.as_bytes());
16    format!("{:x}", hasher.finalize())
17}
18
19/// Calculate SHA256 checksum of a file
20pub async fn calculate_file_checksum<P: AsRef<Path>>(file_path: P) -> Result<String, std::io::Error> {
21    let content = fs::read_to_string(file_path).await?;
22    Ok(calculate_checksum(&content))
23}
24
25/// Extract description from migration filename
26pub fn extract_description_from_filename(filename: &str) -> String {
27    // Expected format: 20250115_001_add_user_table.cql
28    let stem = filename.trim_end_matches(".cql");
29
30    // Split by underscore and take everything after the second underscore
31    let parts: Vec<&str> = stem.split('_').collect();
32    if parts.len() >= 3 {
33        parts[2..].join("_").replace('_', " ")
34    } else {
35        stem.to_string()
36    }
37}
38
39/// Extract version from migration filename
40pub fn extract_version_from_filename(filename: &str) -> Option<String> {
41    // Expected format: 20250115_001_add_user_table.cql
42    let stem = filename.trim_end_matches(".cql");
43
44    // Check if it matches the expected pattern: YYYYMMDD_NNN_description
45    if let Some(version_part) = stem.split('_').take(2).collect::<Vec<_>>().join("_").into() {
46        // Validate that the first part is a valid date format
47        if version_part.len() >= 9 {
48            let (date_part, seq_part) = version_part.split_at(8);
49            if date_part.chars().all(|c| c.is_ascii_digit()) &&
50                seq_part.starts_with('_') &&
51                seq_part[1..].chars().all(|c| c.is_ascii_digit()) {
52                return Some(stem.to_string());
53            }
54        }
55    }
56
57    None
58}
59
60/// Format a timestamp for display
61pub fn format_timestamp(timestamp: DateTime<Utc>) -> String {
62    timestamp.format("%Y-%m-%d %H:%M:%S UTC").to_string()
63}
64
65/// Validate migration filename format
66pub fn is_valid_migration_filename(filename: &str) -> bool {
67    extract_version_from_filename(filename).is_some()
68}
69
70/// Create a normalized migration filename
71pub fn create_migration_filename(description: &str) -> String {
72    let version = generate_migration_version();
73    let normalized_desc = description
74        .chars()
75        .map(|c| if c.is_alphanumeric() { c } else { '_' })
76        .collect::<String>()
77        .to_lowercase();
78
79    format!("{}_{}.cql", version, normalized_desc)
80}
81
82/// Parse migration content to extract UP and DOWN sections
83pub fn parse_migration_content(content: &str) -> Result<(String, Option<String>), String> {
84    let lines: Vec<&str> = content.lines().collect();
85    let mut up_section = Vec::new();
86    let mut down_section = Vec::new();
87    let mut current_section = None;
88
89    for line in lines {
90        let trimmed = line.trim();
91
92        if trimmed.starts_with("-- UP") || trimmed.starts_with("-- +migrate Up") {
93            current_section = Some("UP");
94            continue;
95        } else if trimmed.starts_with("-- DOWN") || trimmed.starts_with("-- +migrate Down") {
96            current_section = Some("DOWN");
97            continue;
98        }
99
100        // Skip comments and empty lines at the beginning
101        if current_section.is_none() && (trimmed.is_empty() || trimmed.starts_with("--")) {
102            continue;
103        }
104
105        // If no section marker found, assume it's all UP
106        if current_section.is_none() {
107            current_section = Some("UP");
108        }
109
110        match current_section {
111            Some("UP") => up_section.push(line),
112            Some("DOWN") => down_section.push(line),
113            _ => {}
114        }
115    }
116
117    let up_content = up_section.join("\n").trim().to_string();
118    let down_content = if down_section.is_empty() {
119        None
120    } else {
121        Some(down_section.join("\n").trim().to_string())
122    };
123
124    if up_content.is_empty() {
125        return Err("Migration must contain at least UP section with CQL statements".to_string());
126    }
127
128    Ok((up_content, down_content))
129}
130
131/// Generate migration template content
132pub fn generate_migration_template(description: &str) -> String {
133    format!(
134        r#"-- Migration: {}
135-- Created at: {}
136
137-- +migrate Up
138-- Add your UP migration statements here
139-- Example:
140-- CREATE TABLE IF NOT EXISTS example_table (
141--     id UUID PRIMARY KEY,
142--     name TEXT,
143--     created_at TIMESTAMP
144-- );
145
146-- +migrate Down
147-- Add your DOWN migration statements here (optional)
148-- Example:
149-- DROP TABLE IF EXISTS example_table;
150"#,
151        description,
152        Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
153    )
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_extract_description_from_filename() {
162        assert_eq!(
163            extract_description_from_filename("20250115_001_add_user_table.cql"),
164            "add user table"
165        );
166        assert_eq!(
167            extract_description_from_filename("20250115_002_create_indexes.cql"),
168            "create indexes"
169        );
170    }
171
172    #[test]
173    fn test_extract_version_from_filename() {
174        assert_eq!(
175            extract_version_from_filename("20250115_001_add_user_table.cql"),
176            Some("20250115_001_add_user_table".to_string())
177        );
178        assert_eq!(
179            extract_version_from_filename("invalid_filename.cql"),
180            None
181        );
182    }
183
184    #[test]
185    fn test_calculate_checksum() {
186        let content = "CREATE TABLE test (id UUID PRIMARY KEY);";
187        let checksum = calculate_checksum(content);
188        assert_eq!(checksum.len(), 64); // SHA256 produces 64 hex characters
189    }
190
191    #[test]
192    fn test_parse_migration_content() {
193        let content = r#"
194-- Migration description
195
196-- +migrate Up
197CREATE TABLE users (id UUID PRIMARY KEY);
198
199-- +migrate Down
200DROP TABLE users;
201"#;
202
203        let (up, down) = parse_migration_content(content).unwrap();
204        assert!(up.contains("CREATE TABLE users"));
205        assert!(down.unwrap().contains("DROP TABLE users"));
206    }
207}