Skip to main content

sz_rust_cli/cmd/
migrate.rs

1//! `migrate` / `migrate:status` 命令 — 对齐 PHP `think migrate*`
2//!
3//! ## PHP 对齐
4//!
5//! PHP `migrate:status` 输出表格:
6//! ```text
7//! +---------+------------------+---------------------+
8//! | Version | Migration Name   | Run Time            |
9//! +---------+------------------+---------------------+
10//! | 001     | create_users     | 2024-01-01 00:00:00 |
11//! | 002     | add_index        | Pending             |
12//! +---------+------------------+---------------------+
13//! ```
14//!
15//! Rust 端使用 `sz-orm-core::migration::{FileMigrationResolver, MigrationResolver}` 读取迁移目录。
16
17use std::path::{Path, PathBuf};
18
19use clap::Args;
20
21use crate::error::CliError;
22
23/// `migrate` 命令参数
24#[derive(Args, Debug)]
25pub struct MigrateArgs {
26    /// 回滚最后一批迁移(对齐 PHP `migrate:rollback`)
27    #[arg(long)]
28    pub rollback: bool,
29
30    /// 迁移目录(默认 `migrations`)
31    #[arg(short = 'p', long, default_value = "migrations")]
32    pub path: String,
33}
34
35/// 执行 migrate 命令
36///
37/// - 无 `--rollback`:执行所有待迁移(对齐 `php think migrate`)
38/// - 有 `--rollback`:回滚最后一批(对齐 `php think migrate:rollback`)
39pub fn execute_migrate(args: &MigrateArgs) -> Result<(), CliError> {
40    let path = PathBuf::from(&args.path);
41
42    if !path.exists() {
43        return Err(CliError::Migration(format!(
44            "Migration directory not found: {}",
45            path.display()
46        )));
47    }
48
49    if args.rollback {
50        println!("Rolling back last batch in: {}", path.display());
51        // 离线模式:仅列出回滚将影响的迁移
52        let migrations = list_migrations(&path)?;
53        if migrations.is_empty() {
54            println!("No migrations to rollback.");
55            return Ok(());
56        }
57        // 模拟回滚:显示最后一个迁移
58        if let Some(last) = migrations.last() {
59            println!("Would rollback: {} ({})", last.0, last.1);
60        }
61        println!("Note: Actual rollback requires database connection.");
62    } else {
63        println!("Running migrations in: {}", path.display());
64        let migrations = list_migrations(&path)?;
65        if migrations.is_empty() {
66            println!("No migrations found.");
67            return Ok(());
68        }
69        for (version, name) in &migrations {
70            println!("  Would apply: {} ({})", version, name);
71        }
72        println!(
73            "Total: {} migration(s). Note: Actual execution requires database connection.",
74            migrations.len()
75        );
76    }
77
78    Ok(())
79}
80
81/// 执行 migrate:status 命令
82///
83/// 对齐 PHP `php think migrate:status`,输出表格格式的迁移状态。
84pub fn execute_status(path: &str) -> Result<(), CliError> {
85    let path_buf = PathBuf::from(path);
86
87    if !path_buf.exists() {
88        return Err(CliError::Migration(format!(
89            "Migration directory not found: {}",
90            path_buf.display()
91        )));
92    }
93
94    let migrations = list_migrations(&path_buf)?;
95
96    if migrations.is_empty() {
97        println!("No migrations found in: {}", path_buf.display());
98        return Ok(());
99    }
100
101    // 表格输出(对齐 PHP migrate:status 格式)
102    println!(
103        "{:<15} {:<30} {:<20}",
104        "Version", "Migration Name", "Status"
105    );
106    println!("{}", "-".repeat(65));
107
108    for (version, name) in &migrations {
109        // 离线模式:无法确定是否已执行,统一显示 "Pending*"
110        println!("{:<15} {:<30} {:<20}", version, name, "Pending*");
111    }
112
113    println!();
114    println!("* Status cannot be determined without database connection (offline mode).");
115
116    Ok(())
117}
118
119/// 列出迁移目录中的所有迁移
120///
121/// 对齐 `FileMigrationResolver::resolve()` 的文件名解析逻辑:
122/// `<version>_<name>_up.sql` / `<version>_<name>_down.sql`
123fn list_migrations(path: &Path) -> Result<Vec<(String, String)>, CliError> {
124    let mut versions: std::collections::BTreeMap<String, String> =
125        std::collections::BTreeMap::new();
126
127    let entries = std::fs::read_dir(path)?;
128    for entry in entries {
129        let entry = entry?;
130        let file_path = entry.path();
131        let filename = match file_path.file_stem().and_then(|s| s.to_str()) {
132            Some(name) => name.to_string(),
133            None => continue,
134        };
135
136        // 解析文件名:<version>_<name>_up 或 <version>_<name>_down
137        let base = if let Some(rest) = filename.strip_suffix("_up") {
138            rest
139        } else if let Some(rest) = filename.strip_suffix("_down") {
140            rest
141        } else {
142            &filename
143        };
144
145        if let Some(underscore_pos) = base.find('_') {
146            let version = base[..underscore_pos].to_string();
147            let name = base[underscore_pos + 1..].to_string();
148            versions.entry(version).or_insert(name);
149        } else {
150            versions
151                .entry(base.to_string())
152                .or_insert_with(|| base.to_string());
153        }
154    }
155
156    Ok(versions.into_iter().collect())
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use std::fs;
163    use std::io::Write;
164
165    fn create_test_migration(dir: &Path, version: &str, name: &str) {
166        let up_name = format!("{}_{}_up.sql", version, name);
167        let down_name = format!("{}_{}_down.sql", version, name);
168
169        let up_path = dir.join(up_name);
170        let down_path = dir.join(down_name);
171
172        let mut up_file = fs::File::create(&up_path).unwrap();
173        writeln!(up_file, "-- {} up", name).unwrap();
174
175        let mut down_file = fs::File::create(&down_path).unwrap();
176        writeln!(down_file, "-- {} down", name).unwrap();
177    }
178
179    #[test]
180    fn test_list_migrations_empty() {
181        let temp = tempfile::tempdir().unwrap();
182        let path = temp.path().to_path_buf();
183        let result = list_migrations(&path).unwrap();
184        assert!(result.is_empty());
185    }
186
187    #[test]
188    fn test_list_migrations_with_files() {
189        let temp = tempfile::tempdir().unwrap();
190        let path = temp.path().to_path_buf();
191
192        create_test_migration(&path, "001", "create_users");
193        create_test_migration(&path, "002", "add_index");
194
195        let result = list_migrations(&path).unwrap();
196        assert_eq!(result.len(), 2);
197        assert_eq!(result[0], ("001".to_string(), "create_users".to_string()));
198        assert_eq!(result[1], ("002".to_string(), "add_index".to_string()));
199    }
200
201    #[test]
202    fn test_execute_status_nonexistent_dir() {
203        let result = execute_status("/nonexistent/path/migrations");
204        assert!(matches!(result, Err(CliError::Migration(_))));
205    }
206
207    #[test]
208    fn test_execute_status_empty_dir() {
209        let temp = tempfile::tempdir().unwrap();
210        let path = temp.path().to_str().unwrap();
211        let result = execute_status(path);
212        assert!(result.is_ok());
213    }
214
215    #[test]
216    fn test_execute_status_with_migrations() {
217        let temp = tempfile::tempdir().unwrap();
218        let path = temp.path().to_path_buf();
219        create_test_migration(&path, "001", "create_users");
220
221        let path_str = temp.path().to_str().unwrap();
222        let result = execute_status(path_str);
223        assert!(result.is_ok());
224    }
225
226    #[test]
227    fn test_execute_migrate_nonexistent_dir() {
228        let args = MigrateArgs {
229            rollback: false,
230            path: "/nonexistent/migrations".to_string(),
231        };
232        let result = execute_migrate(&args);
233        assert!(matches!(result, Err(CliError::Migration(_))));
234    }
235
236    #[test]
237    fn test_execute_migrate_empty_dir() {
238        let temp = tempfile::tempdir().unwrap();
239        let args = MigrateArgs {
240            rollback: false,
241            path: temp.path().to_str().unwrap().to_string(),
242        };
243        let result = execute_migrate(&args);
244        assert!(result.is_ok());
245    }
246
247    #[test]
248    fn test_execute_migrate_with_files() {
249        let temp = tempfile::tempdir().unwrap();
250        let path = temp.path().to_path_buf();
251        create_test_migration(&path, "001", "create_users");
252
253        let args = MigrateArgs {
254            rollback: false,
255            path: temp.path().to_str().unwrap().to_string(),
256        };
257        let result = execute_migrate(&args);
258        assert!(result.is_ok());
259    }
260
261    #[test]
262    fn test_execute_migrate_rollback() {
263        let temp = tempfile::tempdir().unwrap();
264        let path = temp.path().to_path_buf();
265        create_test_migration(&path, "001", "create_users");
266        create_test_migration(&path, "002", "add_index");
267
268        let args = MigrateArgs {
269            rollback: true,
270            path: temp.path().to_str().unwrap().to_string(),
271        };
272        let result = execute_migrate(&args);
273        assert!(result.is_ok());
274    }
275}