Skip to main content

wow_sharedmedia/
lua_io.rs

1//! Read/write data.lua via mlua.
2//!
3//! Lua handles its own escaping and table serialization — Rust never touches raw strings.
4
5use 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
13/// Read data.lua from an addon directory and return the parsed AddonData.
14pub(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	// Wrap the script as a function to pass args via ...
26	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	// Extract addon.data
31	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
112/// Write AddonData to data.lua (with BAK backup).
113///
114/// Before writing, creates a numbered backup: data.lua.1.bak, data.lua.2.bak, ...
115/// When `max_backups` is zero, backup creation is skipped entirely.
116/// After creating a backup, old backups beyond `max_backups` are pruned (oldest first).
117pub(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	// Create numbered backup before overwriting (skip when max_backups == 0)
121	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	// Generate the Lua content
132	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	// Atomic write: .tmp → rename
141	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	// Entries as array
230	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(); // no BAK (first write)
355		write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap(); // creates .1.bak
356		write_data(dir.path(), &data, DEFAULT_MAX_BACKUPS).unwrap(); // creates .2.bak
357
358		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		// Valid Lua but no addon.data
516		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		// Valid Lua, addon.data is a table, but entries is a string instead of table
530		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		// Modify and write again
559		let mut data2 = data1.clone();
560		data2.entries[0].key = "Modified".into();
561		write_data(dir.path(), &data2, DEFAULT_MAX_BACKUPS).unwrap();
562
563		// BAK should contain the original
564		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		// Current file should have the modification
569		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		// Second write creates BAK
583		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		// tmp file should be cleaned up
596		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}