1use crate::db_type::DbType;
6use crate::error::DbError;
7use std::path::PathBuf;
8
9pub struct Migration {
10 pub version: String,
11 pub name: String,
12 pub sql_up: String,
13 pub sql_down: String,
14 pub batch: i32,
15 pub executed_at: Option<chrono::DateTime<chrono::Utc>>,
16}
17
18impl Migration {
19 pub fn new(version: &str, name: &str, sql_up: &str, sql_down: &str) -> Self {
20 Self {
21 version: version.to_string(),
22 name: name.to_string(),
23 sql_up: sql_up.to_string(),
24 sql_down: sql_down.to_string(),
25 batch: 0,
26 executed_at: None,
27 }
28 }
29
30 pub fn with_batch(mut self, batch: i32) -> Self {
31 self.batch = batch;
32 self
33 }
34
35 pub fn with_executed_at(mut self, time: chrono::DateTime<chrono::Utc>) -> Self {
36 self.executed_at = Some(time);
37 self
38 }
39}
40
41impl std::fmt::Debug for Migration {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.debug_struct("Migration")
44 .field("version", &self.version)
45 .field("name", &self.name)
46 .field("batch", &self.batch)
47 .finish()
48 }
49}
50
51pub trait MigrationResolver: Send + Sync {
52 fn resolve(&self, db_type: DbType) -> Result<Vec<Migration>, DbError>;
53}
54
55pub struct FileMigrationResolver {
56 pub path: PathBuf,
57}
58
59impl FileMigrationResolver {
60 pub fn new(path: PathBuf) -> Self {
61 Self { path }
62 }
63}
64
65impl MigrationResolver for FileMigrationResolver {
66 fn resolve(&self, db_type: DbType) -> Result<Vec<Migration>, DbError> {
67 let mut migrations = Vec::new();
68
69 let entries = match std::fs::read_dir(&self.path) {
71 Ok(entries) => entries,
72 Err(e) => {
73 return Err(DbError::MigrationError(format!(
74 "Cannot read migration directory {}: {}",
75 self.path.display(),
76 e
77 )));
78 }
79 };
80
81 let _ = db_type; let mut sql_files: Vec<std::path::PathBuf> = Vec::new();
85 for entry in entries {
86 let entry = entry.map_err(|e| {
87 DbError::MigrationError(format!("Cannot read directory entry: {}", e))
88 })?;
89 let path = entry.path();
90 if path.extension().and_then(|s| s.to_str()) == Some("sql") {
91 sql_files.push(path);
92 }
93 }
94
95 sql_files.sort();
97
98 let mut version_map: std::collections::HashMap<
101 String,
102 (Option<String>, Option<String>, String),
103 > = std::collections::HashMap::new();
104
105 for path in sql_files {
106 let filename = match path.file_stem().and_then(|s| s.to_str()) {
107 Some(name) => name.to_string(),
108 None => continue,
109 };
110
111 let content = std::fs::read_to_string(&path).map_err(|e| {
112 DbError::MigrationError(format!(
113 "Cannot read migration file {}: {}",
114 path.display(),
115 e
116 ))
117 })?;
118
119 if filename.ends_with("_up") {
121 let base = &filename[..filename.len() - 3];
122 let (version, name) = parse_migration_filename(base);
123 let entry = version_map
124 .entry(version.clone())
125 .or_insert((None, None, name));
126 entry.0 = Some(content);
127 } else if filename.ends_with("_down") {
128 let base = &filename[..filename.len() - 5];
129 let (version, name) = parse_migration_filename(base);
130 let entry = version_map
131 .entry(version.clone())
132 .or_insert((None, None, name));
133 entry.1 = Some(content);
134 } else {
135 let (version, name) = parse_migration_filename(&filename);
137 let entry = version_map
138 .entry(version.clone())
139 .or_insert((None, None, name));
140 if entry.0.is_none() {
141 entry.0 = Some(content);
142 }
143 }
144 }
145
146 type VersionEntry = (Option<String>, Option<String>, String);
148 let mut sorted_versions: Vec<(String, VersionEntry)> = version_map.into_iter().collect();
149 sorted_versions.sort_by(|a, b| a.0.cmp(&b.0));
150
151 for (version, (sql_up, sql_down, name)) in sorted_versions {
152 let migration = Migration::new(
153 &version,
154 &name,
155 sql_up.unwrap_or_default().as_str(),
156 sql_down.unwrap_or_default().as_str(),
157 );
158 migrations.push(migration);
159 }
160
161 Ok(migrations)
162 }
163}
164
165fn parse_migration_filename(filename: &str) -> (String, String) {
167 if let Some(underscore_pos) = filename.find('_') {
168 let version = filename[..underscore_pos].to_string();
169 let name = filename[underscore_pos + 1..].to_string();
170 (version, name)
171 } else {
172 (filename.to_string(), filename.to_string())
174 }
175}
176
177pub struct MigrationContext {
178 pub table_name: String,
179 pub connection: Option<Box<dyn crate::pool::Connection>>,
180 pub db_type: Option<DbType>,
182}
183
184impl Default for MigrationContext {
185 fn default() -> Self {
186 Self {
187 table_name: "__migrations".to_string(),
188 connection: None,
189 db_type: None,
190 }
191 }
192}
193
194impl MigrationContext {
195 pub fn with_db_type(mut self, db_type: DbType) -> Self {
197 self.db_type = Some(db_type);
198 self
199 }
200}
201
202fn supports_ddl_transactions(db_type: DbType) -> bool {
211 matches!(db_type, DbType::PostgreSQL | DbType::Sqlite)
212}
213
214#[derive(Debug, Clone, PartialEq)]
215pub enum MigrationDirection {
216 Up,
217 Down,
218}
219
220pub struct Migrator {
221 context: MigrationContext,
222 migrations: Vec<Migration>,
223}
224
225impl Migrator {
226 pub fn new(context: MigrationContext) -> Self {
227 Self {
228 context,
229 migrations: Vec::new(),
230 }
231 }
232
233 pub fn add_migration(mut self, migration: Migration) -> Self {
234 self.migrations.push(migration);
235 self
236 }
237
238 pub fn add_migrations(mut self, migrations: Vec<Migration>) -> Self {
239 self.migrations.extend(migrations);
240 self
241 }
242
243 pub fn get_migrations(&self) -> &Vec<Migration> {
244 &self.migrations
245 }
246
247 pub fn get_pending_migrations(&self) -> Vec<&Migration> {
248 self.migrations.iter().filter(|m| m.batch == 0).collect()
249 }
250
251 pub fn get_applied_migrations(&self) -> Vec<&Migration> {
252 self.migrations.iter().filter(|m| m.batch > 0).collect()
253 }
254
255 pub fn latest_version(&self) -> Option<&str> {
256 self.migrations.last().map(|m| m.version.as_str())
257 }
258
259 pub fn find_migration(&self, version: &str) -> Option<&Migration> {
260 self.migrations.iter().find(|m| m.version == version)
261 }
262
263 pub fn check_version_conflicts(&self) -> Result<(), DbError> {
268 let mut seen = std::collections::HashSet::new();
269 for m in &self.migrations {
270 if !seen.insert(&m.version) {
271 return Err(DbError::MigrationError(format!(
272 "迁移版本冲突:版本号 '{}' 重复定义",
273 m.version
274 )));
275 }
276 }
277 Ok(())
278 }
279
280 pub async fn migrate(&mut self) -> Result<Vec<String>, DbError> {
286 self.check_version_conflicts()?;
288
289 let mut applied = Vec::new();
290 let current_batch = self.migrations.iter().map(|m| m.batch).max().unwrap_or(0) + 1;
291
292 let pending_indices: Vec<usize> = self
294 .migrations
295 .iter()
296 .enumerate()
297 .filter(|(_, m)| m.batch == 0)
298 .map(|(idx, _)| idx)
299 .collect();
300
301 if pending_indices.is_empty() {
302 return Ok(applied);
303 }
304
305 let use_transaction = self
307 .context
308 .db_type
309 .map(supports_ddl_transactions)
310 .unwrap_or(false);
311
312 if use_transaction {
314 if let Some(ref mut conn) = self.context.connection {
315 conn.begin_transaction().await?;
316 }
317 }
318
319 for migration_idx in &pending_indices {
321 let sql_up = self.migrations[*migration_idx].sql_up.clone();
322
323 let exec_result = async {
324 if let Some(ref mut conn) = self.context.connection {
325 if !sql_up.is_empty() {
326 conn.execute(&sql_up).await?;
327 }
328 }
329 Ok::<(), DbError>(())
330 }
331 .await;
332
333 if let Err(e) = exec_result {
334 if use_transaction {
336 if let Some(ref mut conn) = self.context.connection {
337 let _ = conn.rollback().await;
338 }
339 }
340 return Err(e);
341 }
342
343 let now = chrono::Utc::now();
345 self.migrations[*migration_idx].batch = current_batch;
346 self.migrations[*migration_idx].executed_at = Some(now);
347
348 applied.push(self.migrations[*migration_idx].version.clone());
349 }
350
351 if use_transaction {
353 if let Some(ref mut conn) = self.context.connection {
354 conn.commit().await?;
355 }
356 }
357
358 Ok(applied)
359 }
360
361 pub async fn rollback(&mut self, version: &str) -> Result<(), DbError> {
363 let migration_idx = self
364 .migrations
365 .iter()
366 .position(|m| m.version == version)
367 .ok_or_else(|| DbError::MigrationError(format!("Migration {} not found", version)))?;
368
369 if self.migrations[migration_idx].batch == 0 {
370 return Err(DbError::MigrationError(format!(
371 "Migration {} not applied",
372 version
373 )));
374 }
375
376 let sql_down = self.migrations[migration_idx].sql_down.clone();
377
378 if let Some(ref mut conn) = self.context.connection {
379 if !sql_down.is_empty() {
380 conn.execute(&sql_down).await?;
381 }
382 }
383
384 self.migrations[migration_idx].batch = 0;
385 self.migrations[migration_idx].executed_at = None;
386 Ok(())
387 }
388
389 pub async fn up(&mut self, target_version: Option<&str>) -> Result<Vec<String>, DbError> {
391 self.check_version_conflicts()?;
393
394 let mut applied = Vec::new();
395 let current_batch = self.migrations.iter().map(|m| m.batch).max().unwrap_or(0) + 1;
396
397 for migration in &mut self.migrations {
398 if migration.batch > 0 {
399 continue; }
401
402 if let Some(target) = target_version {
403 if migration.version.as_str() > target {
404 break; }
406 }
407
408 let sql_up = migration.sql_up.clone();
409 if let Some(ref mut conn) = self.context.connection {
410 if !sql_up.is_empty() {
411 conn.execute(&sql_up).await?;
412 }
413 }
414
415 migration.batch = current_batch;
416 migration.executed_at = Some(chrono::Utc::now());
417 applied.push(migration.version.clone());
418 }
419
420 Ok(applied)
421 }
422
423 pub async fn down(&mut self, target_version: Option<&str>) -> Result<Vec<String>, DbError> {
425 self.check_version_conflicts()?;
427
428 let mut rolled_back = Vec::new();
429
430 let mut indices: Vec<usize> = (0..self.migrations.len()).collect();
432 indices.reverse();
433
434 for idx in indices {
435 let migration = &mut self.migrations[idx];
436 if migration.batch == 0 {
437 continue; }
439
440 if let Some(target) = target_version {
441 if migration.version.as_str() <= target {
442 break; }
444 }
445
446 let sql_down = migration.sql_down.clone();
447 if let Some(ref mut conn) = self.context.connection {
448 if !sql_down.is_empty() {
449 conn.execute(&sql_down).await?;
450 }
451 }
452
453 migration.batch = 0;
454 migration.executed_at = None;
455 rolled_back.push(migration.version.clone());
456 }
457
458 Ok(rolled_back)
459 }
460
461 pub async fn reset(&mut self) -> Result<Vec<String>, DbError> {
463 self.down(None).await?;
465 self.migrate().await
467 }
468
469 pub async fn refresh(&mut self) -> Result<Vec<String>, DbError> {
471 self.reset().await
472 }
473
474 pub fn progress(&self) -> MigrationProgress {
476 let total = self.migrations.len();
477 let applied = self.migrations.iter().filter(|m| m.batch > 0).count();
478 MigrationProgress::new(total, applied)
479 }
480}
481
482#[derive(Debug, Clone)]
483pub struct MigrationProgress {
484 pub total: usize,
485 pub applied: usize,
486 pub pending: usize,
487 pub current_batch: i32,
488}
489
490impl MigrationProgress {
491 pub fn new(total: usize, applied: usize) -> Self {
492 Self {
493 total,
494 applied,
495 pending: total - applied,
496 current_batch: 0,
497 }
498 }
499
500 pub fn percent_complete(&self) -> f64 {
501 if self.total == 0 {
502 return 100.0;
503 }
504 (self.applied as f64 / self.total as f64) * 100.0
505 }
506}
507
508pub struct SchemaBuilder {
509 table_name: String,
510 columns: Vec<ColumnDef>,
511 indexes: Vec<IndexDef>,
512 foreign_keys: Vec<ForeignKeyDef>,
513 if_not_exists: bool,
514}
515
516impl SchemaBuilder {
517 pub fn new(table_name: &str) -> Self {
518 Self {
519 table_name: table_name.to_string(),
520 columns: Vec::new(),
521 indexes: Vec::new(),
522 foreign_keys: Vec::new(),
523 if_not_exists: true,
524 }
525 }
526
527 pub fn add_column(mut self, column: ColumnDef) -> Self {
528 self.columns.push(column);
529 self
530 }
531
532 pub fn add_index(mut self, index: IndexDef) -> Self {
533 self.indexes.push(index);
534 self
535 }
536
537 pub fn add_foreign_key(mut self, fk: ForeignKeyDef) -> Self {
538 self.foreign_keys.push(fk);
539 self
540 }
541
542 pub fn if_not_exists(mut self, value: bool) -> Self {
543 self.if_not_exists = value;
544 self
545 }
546
547 pub fn build(&self, db_type: DbType) -> String {
548 let mut sql = String::new();
549 sql.push_str("CREATE TABLE ");
550 if self.if_not_exists {
551 sql.push_str("IF NOT EXISTS ");
552 }
553 sql.push_str(&self.table_name);
554 sql.push_str(" (");
555
556 let col_defs: Vec<String> = self.columns.iter().map(|c| c.build(db_type)).collect();
557 sql.push_str(&col_defs.join(", "));
558
559 for index in &self.indexes {
560 sql.push_str(", ");
561 sql.push_str(&index.build(db_type));
562 }
563
564 for fk in &self.foreign_keys {
565 sql.push_str(", ");
566 sql.push_str(&fk.build(db_type));
567 }
568
569 sql.push(')');
570 sql
571 }
572}
573
574#[derive(Debug, Clone)]
575pub struct ColumnDef {
576 pub name: String,
577 pub col_type: String,
578 pub length: Option<usize>,
579 pub precision: Option<(u32, u32)>,
580 pub nullable: bool,
581 pub default: Option<String>,
582 pub auto_increment: bool,
583 pub unique: bool,
584 pub comment: Option<String>,
585}
586
587impl ColumnDef {
588 pub fn new(name: &str, col_type: &str) -> Self {
589 Self {
590 name: name.to_string(),
591 col_type: col_type.to_string(),
592 length: None,
593 precision: None,
594 nullable: true,
595 default: None,
596 auto_increment: false,
597 unique: false,
598 comment: None,
599 }
600 }
601
602 pub fn not_null(mut self) -> Self {
603 self.nullable = false;
604 self
605 }
606
607 pub fn default(mut self, value: &str) -> Self {
608 self.default = Some(value.to_string());
609 self
610 }
611
612 pub fn auto_increment(mut self) -> Self {
613 self.auto_increment = true;
614 self
615 }
616
617 pub fn unique(mut self) -> Self {
618 self.unique = true;
619 self
620 }
621
622 pub fn comment(mut self, comment: &str) -> Self {
623 self.comment = Some(comment.to_string());
624 self
625 }
626
627 pub fn length(mut self, len: usize) -> Self {
628 self.length = Some(len);
629 self
630 }
631
632 fn build(&self, db_type: DbType) -> String {
633 let mut sql = format!("{} {}", self.name, self.col_type);
634 if let Some(len) = self.length {
635 if matches!(db_type, DbType::MySQL) {
636 sql.push_str(&format!("({})", len));
637 }
638 }
639 if self.auto_increment {
640 match db_type {
641 DbType::MySQL => sql.push_str(" AUTO_INCREMENT"),
642 DbType::PostgreSQL => sql.push_str(" GENERATED BY DEFAULT AS IDENTITY"),
643 DbType::Sqlite => sql.push_str(" AUTOINCREMENT"),
644 _ => {}
645 }
646 }
647 if !self.nullable {
648 sql.push_str(" NOT NULL");
649 }
650 if let Some(ref def) = self.default {
651 sql.push_str(&format!(" DEFAULT {}", def));
652 }
653 if self.unique {
654 sql.push_str(" UNIQUE");
655 }
656 sql
657 }
658}
659
660#[derive(Debug, Clone)]
661pub struct IndexDef {
662 pub name: String,
663 pub columns: Vec<String>,
664 pub unique: bool,
665 pub index_type: Option<String>,
666}
667
668impl IndexDef {
669 pub fn new(name: &str, columns: Vec<&str>) -> Self {
670 Self {
671 name: name.to_string(),
672 columns: columns.into_iter().map(|s| s.to_string()).collect(),
673 unique: false,
674 index_type: None,
675 }
676 }
677
678 pub fn unique(mut self) -> Self {
679 self.unique = true;
680 self
681 }
682
683 fn build(&self, _db_type: DbType) -> String {
684 let unique_str = if self.unique { "UNIQUE " } else { "" };
685 format!(
686 "{}KEY {} ({})",
687 unique_str,
688 self.name,
689 self.columns.join(", ")
690 )
691 }
692}
693
694#[derive(Debug, Clone)]
695pub struct ForeignKeyDef {
696 pub name: String,
697 pub column: String,
698 pub referenced_table: String,
699 pub referenced_column: String,
700 pub on_delete: Option<String>,
701 pub on_update: Option<String>,
702}
703
704impl ForeignKeyDef {
705 pub fn new(name: &str, column: &str, referenced_table: &str, referenced_column: &str) -> Self {
706 Self {
707 name: name.to_string(),
708 column: column.to_string(),
709 referenced_table: referenced_table.to_string(),
710 referenced_column: referenced_column.to_string(),
711 on_delete: None,
712 on_update: None,
713 }
714 }
715
716 pub fn on_delete(mut self, action: &str) -> Self {
717 self.on_delete = Some(action.to_string());
718 self
719 }
720
721 pub fn on_update(mut self, action: &str) -> Self {
722 self.on_update = Some(action.to_string());
723 self
724 }
725
726 fn build(&self, _db_type: DbType) -> String {
727 crate::sql_safety::validate_identifier(&self.name, "foreign key constraint name")
729 .expect("invalid foreign key constraint name");
730 crate::sql_safety::validate_identifier(&self.column, "foreign key column")
731 .expect("invalid foreign key column name");
732 crate::sql_safety::validate_identifier(
733 &self.referenced_table,
734 "foreign key referenced table",
735 )
736 .expect("invalid foreign key referenced table name");
737 crate::sql_safety::validate_identifier(
738 &self.referenced_column,
739 "foreign key referenced column",
740 )
741 .expect("invalid foreign key referenced column name");
742 if let Some(ref on_delete) = self.on_delete {
743 crate::sql_safety::validate_fk_action(on_delete).expect("invalid ON DELETE action");
744 }
745 if let Some(ref on_update) = self.on_update {
746 crate::sql_safety::validate_fk_action(on_update).expect("invalid ON UPDATE action");
747 }
748 let mut sql = format!(
749 "CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})",
750 self.name, self.column, self.referenced_table, self.referenced_column
751 );
752 if let Some(ref on_delete) = self.on_delete {
753 sql.push_str(&format!(" ON DELETE {}", on_delete.trim().to_uppercase()));
754 }
755 if let Some(ref on_update) = self.on_update {
756 sql.push_str(&format!(" ON UPDATE {}", on_update.trim().to_uppercase()));
757 }
758 sql
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 #[test]
767 fn test_migration_new() {
768 let m = Migration::new("001", "create_users", "CREATE TABLE...", "DROP TABLE...");
769 assert_eq!(m.version, "001");
770 assert_eq!(m.name, "create_users");
771 }
772
773 #[test]
774 fn test_migration_with_batch() {
775 let m = Migration::new("001", "create_users", "UP", "DOWN").with_batch(1);
776 assert_eq!(m.batch, 1);
777 }
778
779 #[test]
780 fn test_migrator_latest_version() {
781 let ctx = MigrationContext::default();
782 let migrator = Migrator::new(ctx)
783 .add_migration(Migration::new("001", "v1", "UP", "DOWN"))
784 .add_migration(Migration::new("002", "v2", "UP", "DOWN"));
785
786 assert_eq!(migrator.latest_version(), Some("002"));
787 }
788
789 #[test]
790 fn test_migrator_find_migration() {
791 let ctx = MigrationContext::default();
792 let migrator =
793 Migrator::new(ctx).add_migration(Migration::new("001", "create_users", "UP", "DOWN"));
794
795 assert!(migrator.find_migration("001").is_some());
796 assert!(migrator.find_migration("999").is_none());
797 }
798
799 #[test]
800 fn test_column_def() {
801 let col = ColumnDef::new("id", "INT").not_null().auto_increment();
802 assert_eq!(col.name, "id");
803 assert!(!col.nullable);
804 assert!(col.auto_increment);
805 }
806
807 #[test]
808 fn test_column_build_mysql() {
809 let col = ColumnDef::new("id", "INT").not_null();
810 let sql = col.build(DbType::MySQL);
811 assert!(sql.contains("NOT NULL"));
812 }
813
814 #[test]
815 fn test_index_build() {
816 let idx = IndexDef::new("idx_name", vec!["name"]).unique();
817 let sql = idx.build(DbType::MySQL);
818 assert!(sql.contains("UNIQUE KEY"));
819 }
820
821 #[test]
822 fn test_foreign_key_build() {
823 let fk = ForeignKeyDef::new("fk_user", "user_id", "users", "id").on_delete("CASCADE");
824 let sql = fk.build(DbType::MySQL);
825 assert!(sql.contains("FOREIGN KEY"));
826 assert!(sql.contains("ON DELETE CASCADE"));
827 }
828
829 #[test]
830 fn test_foreign_key_build_normalizes_action_case() {
831 let fk = ForeignKeyDef::new("fk_user", "user_id", "users", "id").on_delete("cascade");
833 let sql = fk.build(DbType::MySQL);
834 assert!(sql.contains("ON DELETE CASCADE"));
835 }
836
837 #[test]
838 #[should_panic(expected = "invalid foreign key column name")]
839 fn test_foreign_key_rejects_sql_injection_in_column() {
840 let fk = ForeignKeyDef::new("fk_user", "user_id; DROP TABLE users", "users", "id");
841 let _ = fk.build(DbType::MySQL);
842 }
843
844 #[test]
845 #[should_panic(expected = "invalid foreign key referenced table name")]
846 fn test_foreign_key_rejects_sql_injection_in_ref_table() {
847 let fk = ForeignKeyDef::new("fk_user", "user_id", "users; DROP TABLE users", "id");
848 let _ = fk.build(DbType::MySQL);
849 }
850
851 #[test]
852 #[should_panic(expected = "invalid ON DELETE action")]
853 fn test_foreign_key_rejects_sql_injection_in_on_delete() {
854 let fk = ForeignKeyDef::new("fk_user", "user_id", "users", "id")
855 .on_delete("CASCADE; DROP TABLE users");
856 let _ = fk.build(DbType::MySQL);
857 }
858
859 #[test]
860 #[should_panic(expected = "invalid ON UPDATE action")]
861 fn test_foreign_key_rejects_invalid_on_update_action() {
862 let fk = ForeignKeyDef::new("fk_user", "user_id", "users", "id").on_update("EVIL_ACTION");
863 let _ = fk.build(DbType::MySQL);
864 }
865
866 #[test]
867 fn test_schema_builder() {
868 let schema = SchemaBuilder::new("users")
869 .add_column(ColumnDef::new("id", "INT").not_null().auto_increment())
870 .add_column(ColumnDef::new("name", "VARCHAR").length(255));
871
872 let sql = schema.build(DbType::MySQL);
873 assert!(sql.contains("CREATE TABLE"));
874 assert!(sql.contains("users"));
875 }
876
877 #[test]
878 fn test_migration_progress() {
879 let progress = MigrationProgress::new(10, 4);
880 assert_eq!(progress.pending, 6);
881 assert!((progress.percent_complete() - 40.0).abs() < 0.01);
882 }
883}