1use std::path::Path;
6
7use mlua::{Lua, Table, Value};
8
9use crate::{AddonData, EntryMetadata, Error, MediaEntry, MediaType};
10
11const SERPENT_LUA: &str = include_str!("../vendor/serpent/serpent.lua");
12
13pub(crate) fn read_data(addon_dir: &Path) -> Result<AddonData, Error> {
15 let path = addon_dir.join("data.lua");
16 let content = std::fs::read_to_string(&path).map_err(|e| Error::Io {
17 source: e,
18 path: path.clone(),
19 })?;
20
21 let lua = Lua::new();
22 let addon: Table = lua.create_table()?;
23 let addon_name = crate::addon_name(addon_dir);
24
25 let wrapped = format!("return function(...)\n{}\nend", content);
27 let func: mlua::Function = lua.load(&wrapped).eval()?;
28 func.call::<()>((addon_name.to_string(), addon.clone()))?;
29
30 let data_val: Value = addon.get("data")?;
32 let data_tbl: &Table = match &data_val {
33 Value::Table(t) => t,
34 _ => return Err(Error::DataLuaParse("addon.data is not a table".into())),
35 };
36
37 lua_to_addon_data(data_tbl)
38}
39
40fn lua_to_addon_data(tbl: &Table) -> Result<AddonData, Error> {
41 Ok(AddonData {
42 schema_version: tbl.get("schema_version")?,
43 version: tbl.get("version")?,
44 generated_at: parse_datetime(&tbl.get::<String>("generated_at")?)?,
45 entries: lua_to_entries(tbl.get("entries")?)?,
46 })
47}
48
49fn lua_to_entries(val: Value) -> Result<Vec<MediaEntry>, Error> {
50 let tbl: &Table = match &val {
51 Value::Table(t) => t,
52 Value::Nil => return Ok(Vec::new()),
53 _ => return Err(Error::DataLuaParse("entries is not a table".into())),
54 };
55
56 let mut entries = Vec::new();
57 for pair in tbl.sequence_values::<Table>() {
58 entries.push(lua_to_entry(&pair?)?);
59 }
60 Ok(entries)
61}
62
63fn lua_to_entry(tbl: &Table) -> Result<MediaEntry, Error> {
64 let type_str: String = tbl.get("type")?;
65 let media_type: MediaType = type_str
66 .parse()
67 .map_err(|e: String| Error::DataLuaParse(format!("invalid type '{e}'")))?;
68
69 Ok(MediaEntry {
70 id: parse_uuid(&tbl.get::<String>("id")?)?,
71 media_type,
72 key: tbl.get("key")?,
73 file: tbl.get("file")?,
74 original_name: tbl.get("original_name").ok().flatten(),
75 imported_at: parse_datetime(&tbl.get::<String>("imported_at")?)?,
76 checksum: tbl.get("checksum").ok().flatten(),
77 metadata: lua_to_metadata(tbl.get("metadata")?),
78 tags: tbl.get("tags").unwrap_or_default(),
79 })
80}
81
82fn lua_to_metadata(val: Value) -> Option<EntryMetadata> {
83 let tbl: &Table = match &val {
84 Value::Table(t) => t,
85 _ => return None,
86 };
87
88 Some(EntryMetadata {
89 image_width: tbl.get("image_width").ok().flatten(),
90 image_height: tbl.get("image_height").ok().flatten(),
91 font_family: tbl.get("font_family").ok().flatten(),
92 font_style: tbl.get("font_style").ok().flatten(),
93 font_is_monospace: tbl.get("font_is_monospace").ok().flatten(),
94 font_num_glyphs: tbl.get("font_num_glyphs").ok().flatten(),
95 locales: tbl.get("locales").unwrap_or_default(),
96 audio_duration_secs: tbl.get("audio_duration_secs").ok().flatten(),
97 audio_sample_rate: tbl.get("audio_sample_rate").ok().flatten(),
98 audio_channels: tbl.get("audio_channels").ok().flatten(),
99 })
100}
101
102fn parse_uuid(s: &str) -> Result<uuid::Uuid, Error> {
103 uuid::Uuid::parse_str(s).map_err(|e| Error::DataLuaParse(format!("invalid UUID: {e}")))
104}
105
106fn parse_datetime(s: &str) -> Result<chrono::DateTime<chrono::Utc>, Error> {
107 chrono::DateTime::parse_from_rfc3339(s)
108 .map(|dt| dt.with_timezone(&chrono::Utc))
109 .map_err(|e| Error::DataLuaParse(format!("invalid datetime: {e}")))
110}
111
112pub(crate) fn write_data(addon_dir: &Path, data: &AddonData, max_backups: u32) -> Result<(), Error> {
118 let data_path = addon_dir.join("data.lua");
119
120 if max_backups > 0 && data_path.exists() {
122 let bak_num = next_bak_number(addon_dir);
123 let bak_path = addon_dir.join(format!("data.lua.{bak_num}.bak"));
124 std::fs::copy(&data_path, &bak_path).map_err(|e| Error::Io {
125 source: e,
126 path: bak_path,
127 })?;
128 prune_backups(addon_dir, max_backups);
129 }
130
131 let body = serialize_addon_data(data)?;
133 let content = format!(
134 "-- Generated: {}\n-- Tool: wow-sharedmedia v{}\n\nlocal _, addon = ...\n\naddon.data = {}\n",
135 data.generated_at.format("%Y-%m-%dT%H:%M:%SZ"),
136 data.version,
137 body,
138 );
139
140 let tmp_path = addon_dir.join("data.lua.tmp");
142 std::fs::write(&tmp_path, &content).map_err(|e| Error::Io {
143 source: e,
144 path: tmp_path.clone(),
145 })?;
146 std::fs::rename(&tmp_path, &data_path).map_err(|e| Error::Io {
147 source: e,
148 path: data_path,
149 })?;
150
151 Ok(())
152}
153
154fn next_bak_number(addon_dir: &Path) -> u32 {
155 let mut max: u32 = 0;
156 for entry in std::fs::read_dir(addon_dir).into_iter().flatten() {
157 let entry = match entry {
158 Ok(e) => e,
159 Err(_) => continue,
160 };
161 let os_name = entry.file_name();
162 let name: &str = match os_name.to_str() {
163 Some(s) => s,
164 None => continue,
165 };
166 if let Some(rest) = name.strip_prefix("data.lua.")
167 && let Some(rest) = rest.strip_suffix(".bak")
168 && let Ok(n) = rest.parse::<u32>()
169 {
170 max = max.max(n);
171 }
172 }
173 max + 1
174}
175
176fn prune_backups(addon_dir: &Path, max_backups: u32) {
177 let mut bak_numbers: Vec<u32> = Vec::new();
178 for entry in std::fs::read_dir(addon_dir).into_iter().flatten() {
179 let entry = match entry {
180 Ok(e) => e,
181 Err(_) => continue,
182 };
183 let os_name = entry.file_name();
184 let name: &str = match os_name.to_str() {
185 Some(s) => s,
186 None => continue,
187 };
188 if let Some(rest) = name.strip_prefix("data.lua.")
189 && let Some(rest) = rest.strip_suffix(".bak")
190 && let Ok(n) = rest.parse::<u32>()
191 {
192 bak_numbers.push(n);
193 }
194 }
195 if bak_numbers.len() <= max_backups as usize {
196 return;
197 }
198 bak_numbers.sort();
199 let to_remove = bak_numbers.len() - max_backups as usize;
200 for &n in &bak_numbers[..to_remove] {
201 let path = addon_dir.join(format!("data.lua.{n}.bak"));
202 let _ = std::fs::remove_file(path);
203 }
204}
205
206fn serialize_addon_data(data: &AddonData) -> Result<String, Error> {
207 let lua = Lua::new();
208
209 let serpent: Table = lua.load(SERPENT_LUA).eval()?;
210 let block_fn: mlua::Function = serpent.get("block")?;
211 let tbl = addon_data_to_table(&lua, data)?;
212
213 let opts = lua.create_table()?;
214 opts.set("comment", false)?;
215
216 let body: String = block_fn.call((tbl, opts))?;
217 Ok(body)
218}
219
220fn addon_data_to_table(lua: &Lua, data: &AddonData) -> Result<Table, Error> {
221 let tbl = lua.create_table()?;
222 tbl.set("schema_version", data.schema_version)?;
223 tbl.set("version", data.version.clone())?;
224 tbl.set(
225 "generated_at",
226 data.generated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
227 )?;
228
229 let entries_tbl = lua.create_table()?;
231 for (i, entry) in data.entries.iter().enumerate() {
232 entries_tbl.set(i + 1, entry_to_table(lua, entry)?)?;
233 }
234 tbl.set("entries", entries_tbl)?;
235
236 Ok(tbl)
237}
238
239fn entry_to_table(lua: &Lua, entry: &MediaEntry) -> Result<Table, Error> {
240 let tbl = lua.create_table()?;
241 tbl.set("id", entry.id.to_string())?;
242 tbl.set("type", entry.media_type.to_string())?;
243 tbl.set("key", entry.key.as_str())?;
244 tbl.set("file", entry.file.as_str())?;
245
246 if let Some(ref name) = entry.original_name {
247 tbl.set("original_name", name.clone())?;
248 }
249 tbl.set(
250 "imported_at",
251 entry.imported_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
252 )?;
253 if let Some(ref checksum) = entry.checksum {
254 tbl.set("checksum", checksum.clone())?;
255 }
256 if let Some(ref meta) = entry.metadata {
257 tbl.set("metadata", metadata_to_table(lua, meta)?)?;
258 }
259 if !entry.tags.is_empty() {
260 let tags_tbl = lua.create_table()?;
261 for (i, tag) in entry.tags.iter().enumerate() {
262 tags_tbl.set(i + 1, tag.clone())?;
263 }
264 tbl.set("tags", tags_tbl)?;
265 }
266
267 Ok(tbl)
268}
269
270fn metadata_to_table(lua: &Lua, meta: &EntryMetadata) -> Result<Table, Error> {
271 let tbl = lua.create_table()?;
272 if let Some(w) = meta.image_width {
273 tbl.set("image_width", w)?;
274 }
275 if let Some(h) = meta.image_height {
276 tbl.set("image_height", h)?;
277 }
278 if let Some(ref family) = meta.font_family {
279 tbl.set("font_family", family.clone())?;
280 }
281 if let Some(ref style) = meta.font_style {
282 tbl.set("font_style", style.clone())?;
283 }
284 if let Some(mono) = meta.font_is_monospace {
285 tbl.set("font_is_monospace", mono)?;
286 }
287 if let Some(glyphs) = meta.font_num_glyphs {
288 tbl.set("font_num_glyphs", glyphs)?;
289 }
290 if !meta.locales.is_empty() {
291 let loc_tbl = lua.create_table()?;
292 for (i, loc) in meta.locales.iter().enumerate() {
293 loc_tbl.set(i + 1, loc.clone())?;
294 }
295 tbl.set("locales", loc_tbl)?;
296 }
297 if let Some(dur) = meta.audio_duration_secs {
298 tbl.set("audio_duration_secs", dur)?;
299 }
300 if let Some(rate) = meta.audio_sample_rate {
301 tbl.set("audio_sample_rate", rate)?;
302 }
303 if let Some(ch) = meta.audio_channels {
304 tbl.set("audio_channels", ch)?;
305 }
306 Ok(tbl)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use chrono::Utc;
313 use tempfile::TempDir;
314
315 const DEFAULT_MAX_BACKUPS: u32 = 10;
316
317 fn sample_data() -> AddonData {
318 let mut data = AddonData::empty("0.1.0");
319 data.entries.push(MediaEntry {
320 id: uuid::Uuid::new_v4(),
321 media_type: MediaType::Statusbar,
322 key: "Wind Clean".to_string(),
323 file: "media/statusbar/wind_clean.tga".to_string(),
324 original_name: None,
325 imported_at: Utc::now(),
326 checksum: Some("sha256:abc123".to_string()),
327 metadata: None,
328 tags: vec!["clean".to_string()],
329 });
330 data
331 }
332
333 #[test]
334 fn test_write_and_read_roundtrip() {
335 let dir = TempDir::new().unwrap();
336 let data = sample_data();
337
338 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
339 let read_back = read_data(dir.path()).unwrap();
340
341 assert_eq!(read_back.schema_version, data.schema_version);
342 assert_eq!(read_back.version, data.version);
343 assert_eq!(read_back.entries.len(), 1);
344 assert_eq!(read_back.entries[0].key, "Wind Clean");
345 assert_eq!(read_back.entries[0].media_type, MediaType::Statusbar);
346 assert_eq!(read_back.entries[0].tags, vec!["clean"]);
347 }
348
349 #[test]
350 fn test_bak_numbering() {
351 let dir = TempDir::new().unwrap();
352 let data = sample_data();
353
354 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap(); write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap(); write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap(); assert!(dir.path().join("data.lua.1.bak").exists());
359 assert!(dir.path().join("data.lua.2.bak").exists());
360 assert!(dir.path().join("data.lua").exists());
361 }
362
363 #[test]
364 fn test_empty_entries_roundtrip() {
365 let dir = TempDir::new().unwrap();
366 let data = AddonData::empty("1.0.0");
367
368 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
369 let read_back = read_data(dir.path()).unwrap();
370
371 assert!(read_back.entries.is_empty());
372 assert_eq!(read_back.schema_version, crate::SCHEMA_VERSION);
373 }
374
375 #[test]
376 fn test_all_media_types_roundtrip() {
377 let dir = TempDir::new().unwrap();
378 let mut data = AddonData::empty("1.0.0");
379 data.entries.push(MediaEntry {
380 id: uuid::Uuid::new_v4(),
381 media_type: MediaType::Statusbar,
382 key: "Bar".into(),
383 file: "media/statusbar/bar.tga".into(),
384 original_name: None,
385 imported_at: Utc::now(),
386 checksum: None,
387 metadata: None,
388 tags: vec![],
389 });
390 data.entries.push(MediaEntry {
391 id: uuid::Uuid::new_v4(),
392 media_type: MediaType::Font,
393 key: "Wind Sans".into(),
394 file: "media/font/wind_sans.ttf".into(),
395 original_name: None,
396 imported_at: Utc::now(),
397 checksum: None,
398 metadata: Some(EntryMetadata {
399 font_family: Some("Wind Sans".into()),
400 locales: vec!["western".into(), "zhCN".into()],
401 ..Default::default()
402 }),
403 tags: vec![],
404 });
405 data.entries.push(MediaEntry {
406 id: uuid::Uuid::new_v4(),
407 media_type: MediaType::Sound,
408 key: "Click".into(),
409 file: "media/sound/click.ogg".into(),
410 original_name: None,
411 imported_at: Utc::now(),
412 checksum: None,
413 metadata: Some(EntryMetadata {
414 audio_duration_secs: Some(0.5),
415 audio_sample_rate: Some(44100),
416 audio_channels: Some(2),
417 ..Default::default()
418 }),
419 tags: vec![],
420 });
421
422 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
423 let read_back = read_data(dir.path()).unwrap();
424
425 assert_eq!(read_back.entries.len(), 3);
426 assert_eq!(read_back.entries[1].media_type, MediaType::Font);
427 assert_eq!(
428 read_back.entries[1].metadata.as_ref().unwrap().locales,
429 vec!["western", "zhCN"]
430 );
431 assert_eq!(
432 read_back.entries[2].metadata.as_ref().unwrap().audio_duration_secs,
433 Some(0.5)
434 );
435 }
436
437 #[test]
438 fn test_read_missing_file() {
439 let dir = TempDir::new().unwrap();
440 let result = read_data(dir.path());
441 assert!(result.is_err());
442 }
443
444 #[test]
445 fn test_special_chars_in_key() {
446 let dir = TempDir::new().unwrap();
447 let mut data = AddonData::empty("1.0.0");
448 data.entries.push(MediaEntry {
449 id: uuid::Uuid::new_v4(),
450 media_type: MediaType::Statusbar,
451 key: "Test \"Quote\"".to_string(),
452 file: "media/statusbar/test.tga".to_string(),
453 original_name: None,
454 imported_at: Utc::now(),
455 checksum: None,
456 metadata: None,
457 tags: vec![],
458 });
459
460 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
461 let read_back = read_data(dir.path()).unwrap();
462 assert_eq!(read_back.entries[0].key, r#"Test "Quote""#);
463 }
464
465 #[test]
466 fn test_chinese_keys_roundtrip() {
467 let dir = TempDir::new().unwrap();
468 let mut data = AddonData::empty("1.0.0");
469 data.entries.push(MediaEntry {
470 id: uuid::Uuid::new_v4(),
471 media_type: MediaType::Statusbar,
472 key: "清风明月".to_string(),
473 file: "media/statusbar/qfmy.tga".to_string(),
474 original_name: None,
475 imported_at: Utc::now(),
476 checksum: None,
477 metadata: None,
478 tags: vec![],
479 });
480
481 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
482 let read_back = read_data(dir.path()).unwrap();
483 assert_eq!(read_back.entries[0].key, "清风明月");
484 }
485
486 #[test]
487 fn test_generated_lua_is_valid() {
488 let dir = TempDir::new().unwrap();
489 let data = sample_data();
490 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
491
492 let content = std::fs::read_to_string(dir.path().join("data.lua")).unwrap();
493
494 assert!(content.contains("Generated: "));
495 assert!(content.contains("Tool: wow-sharedmedia v0.1.0"));
496 assert!(content.contains("local _, addon = ..."));
497 assert!(content.contains("addon.data"));
498 assert!(!content.contains("--[[table:"));
499
500 assert!(read_data(dir.path()).is_ok());
501 }
502
503 #[test]
504 fn test_read_corrupted_lua_syntax() {
505 let dir = TempDir::new().unwrap();
506 std::fs::write(dir.path().join("data.lua"), "this is not valid lua {{{").unwrap();
507
508 let result = read_data(dir.path());
509 assert!(result.is_err());
510 }
511
512 #[test]
513 fn test_read_missing_addon_data_field() {
514 let dir = TempDir::new().unwrap();
515 std::fs::write(dir.path().join("data.lua"), "local _, addon = ...\naddon.other = {}\n").unwrap();
517
518 let result = read_data(dir.path());
519 assert!(result.is_err());
520 match result.unwrap_err() {
521 Error::DataLuaParse(msg) => assert!(msg.contains("not a table")),
522 other => panic!("Expected DataLuaParse, got: {other}"),
523 }
524 }
525
526 #[test]
527 fn test_read_corrupted_entries_field() {
528 let dir = TempDir::new().unwrap();
529 std::fs::write(
531 dir.path().join("data.lua"),
532 "local _, addon = ...\naddon.data = { entries = \"not a table\" }\n",
533 )
534 .unwrap();
535
536 let result = read_data(dir.path());
537 assert!(result.is_err());
538 }
539
540 #[test]
541 fn test_bak_preserves_original_on_write() {
542 let dir = TempDir::new().unwrap();
543 let mut data1 = AddonData::empty("1.0.0");
544 data1.entries.push(MediaEntry {
545 id: uuid::Uuid::new_v4(),
546 media_type: MediaType::Statusbar,
547 key: "Original".into(),
548 file: "media/statusbar/orig.tga".into(),
549 original_name: None,
550 imported_at: Utc::now(),
551 checksum: None,
552 metadata: None,
553 tags: vec![],
554 });
555
556 write_data(dir.path(), &data1, DEFAULT_MAX_BACKUPS).unwrap();
557
558 let mut data2 = data1.clone();
560 data2.entries[0].key = "Modified".into();
561 write_data(dir.path(), &data2, DEFAULT_MAX_BACKUPS).unwrap();
562
563 let bak_content = std::fs::read_to_string(dir.path().join("data.lua.1.bak")).unwrap();
565 assert!(bak_content.contains("Original"));
566 assert!(!bak_content.contains("Modified"));
567
568 let current = std::fs::read_to_string(dir.path().join("data.lua")).unwrap();
570 assert!(current.contains("Modified"));
571 assert!(!current.contains("Original"));
572 }
573
574 #[test]
575 fn test_bak_content_matches_original() {
576 let dir = TempDir::new().unwrap();
577 let data = sample_data();
578
579 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
580 let original_content = std::fs::read_to_string(dir.path().join("data.lua")).unwrap();
581
582 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
584 let bak_content = std::fs::read_to_string(dir.path().join("data.lua.1.bak")).unwrap();
585 assert_eq!(bak_content, original_content);
586 }
587
588 #[test]
589 fn test_write_creates_atomic() {
590 let dir = TempDir::new().unwrap();
591 let data = sample_data();
592
593 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
594
595 assert!(!dir.path().join("data.lua.tmp").exists());
597 assert!(dir.path().join("data.lua").exists());
598 }
599
600 #[test]
601 fn test_write_data_renders_supplied_version_in_header_and_body() {
602 let dir = TempDir::new().unwrap();
603 let mut data = AddonData::empty("9.9.9-test");
604 data.entries.push(MediaEntry {
605 id: uuid::Uuid::new_v4(),
606 media_type: MediaType::Statusbar,
607 key: "Version Probe".into(),
608 file: "media/statusbar/version_probe.tga".into(),
609 original_name: None,
610 imported_at: Utc::now(),
611 checksum: Some("sha256:test".into()),
612 metadata: None,
613 tags: vec![],
614 });
615
616 write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap();
617 let content = std::fs::read_to_string(dir.path().join("data.lua")).unwrap();
618
619 assert!(content.contains("Generated: "));
620 assert!(content.contains("Tool: wow-sharedmedia v9.9.9-test"));
621 assert!(!content.contains("Entries:"));
622 assert!(!content.contains("DO NOT EDIT MANUALLY"));
623 assert!(!content.contains("--[[table:"));
624 assert!(content.contains("version = \"9.9.9-test\""));
625
626 let read_back = read_data(dir.path()).unwrap();
627 assert_eq!(read_back.version, "9.9.9-test");
628 assert_eq!(read_back.entries[0].key, "Version Probe");
629 }
630
631 #[test]
632 fn test_prune_removes_oldest_backups() {
633 let dir = TempDir::new().unwrap();
634 let data = sample_data();
635
636 for _ in 0..5 {
637 write_data(dir.path(), &data, 3).unwrap();
638 }
639
640 let bak_files: Vec<u32> = std::fs::read_dir(dir.path())
641 .unwrap()
642 .flatten()
643 .filter_map(|e| {
644 let name = e.file_name().to_str()?.to_string();
645 name.strip_prefix("data.lua.")?.strip_suffix(".bak")?.parse().ok()
646 })
647 .collect();
648
649 assert_eq!(bak_files.len(), 3);
650 assert!(!bak_files.contains(&1), "should remove oldest bak 1");
651 assert!(bak_files.contains(&2), "should keep bak 2");
652 assert!(bak_files.contains(&3), "should keep bak 3");
653 assert!(bak_files.contains(&4), "should keep bak 4");
654 }
655
656 #[test]
657 fn test_max_backups_zero_skips_backup() {
658 let dir = TempDir::new().unwrap();
659 let data = sample_data();
660
661 write_data(dir.path(), &data, 0).unwrap();
662 write_data(dir.path(), &data, 0).unwrap();
663
664 let bak_files: Vec<String> = std::fs::read_dir(dir.path())
665 .unwrap()
666 .flatten()
667 .filter_map(|e| {
668 let name = e.file_name().to_str()?.to_string();
669 if name.starts_with("data.lua.") && name.ends_with(".bak") {
670 Some(name)
671 } else {
672 None
673 }
674 })
675 .collect();
676
677 assert!(bak_files.is_empty(), "no .bak files should exist when max_backups=0");
678 }
679
680 #[test]
681 fn test_prune_keeps_newest() {
682 let dir = TempDir::new().unwrap();
683 let data = sample_data();
684
685 for _ in 0..10 {
686 write_data(dir.path(), &data, 5).unwrap();
687 }
688
689 let mut bak_numbers: Vec<u32> = std::fs::read_dir(dir.path())
690 .unwrap()
691 .flatten()
692 .filter_map(|e| {
693 let name = e.file_name().to_str()?.to_string();
694 name.strip_prefix("data.lua.")?.strip_suffix(".bak")?.parse().ok()
695 })
696 .collect();
697 bak_numbers.sort();
698
699 assert_eq!(bak_numbers.len(), 5);
700 assert_eq!(bak_numbers, vec![5, 6, 7, 8, 9]);
701 }
702}