Skip to main content

wow_sharedmedia/
media.rs

1//! Stateless media operations — import, remove, update.
2//!
3//! Each operation is atomic: read data.lua → modify → write data.lua.
4//! No in-memory state, no dirty tracking, no separate save/generate/deploy steps.
5
6use std::path::{Path, PathBuf};
7
8use sha2::Digest;
9
10use crate::converter;
11use crate::lua_io;
12use crate::template;
13use crate::{AddonData, EntryMetadata, Error, MediaEntry, MediaType};
14
15const MEDIA_SUBDIRS: &[&str] = &["statusbar", "background", "border", "font", "sound"];
16
17/// Default maximum number of backup files retained per write.
18pub const DEFAULT_MAX_BACKUPS: u32 = 10;
19
20/// Check if a char is a CJK character (Chinese, Japanese, Korean).
21#[inline]
22fn is_cjk_or_hangul(ch: char) -> bool {
23	matches!(ch,
24		'\u{4e00}'..='\u{9fff}' |     // CJK Unified Ideographs
25		'\u{3400}'..='\u{4dbf}' |     // CJK Extension A
26		'\u{f900}'..='\u{faff}' |     // CJK Compatibility Ideographs
27		'\u{ac00}'..='\u{d7af}' |     // Hangul Syllables
28		'\u{3040}'..='\u{309f}' |     // Hiragana
29		'\u{30a0}'..='\u{30ff}' |     // Katakana
30		'\u{31f0}'..='\u{31ff}'       // Katakana Phonetic Extensions
31	)
32}
33
34fn sanitize_filename(name: &str) -> String {
35	let mut result = String::with_capacity(name.len());
36	let mut last_was_underscore = false;
37
38	for ch in name.chars() {
39		if ch.is_ascii_lowercase() || ch.is_ascii_digit() || is_cjk_or_hangul(ch) || ch == '.' || ch == '-' {
40			result.push(ch);
41			last_was_underscore = false;
42		} else if ch.is_ascii_uppercase() {
43			result.push(ch.to_ascii_lowercase());
44			last_was_underscore = false;
45		} else if !last_was_underscore {
46			result.push('_');
47			last_was_underscore = true;
48		}
49	}
50
51	while result.ends_with('_') {
52		result.pop();
53	}
54	while result.starts_with('_') {
55		result.remove(0);
56	}
57
58	if result.is_empty() {
59		"unnamed".to_string()
60	} else {
61		result
62	}
63}
64
65const MAX_IMAGE_SIZE: u64 = 50 * 1024 * 1024;
66const MAX_FONT_SIZE: u64 = 200 * 1024 * 1024;
67const MAX_AUDIO_SIZE: u64 = 50 * 1024 * 1024;
68
69fn current_version() -> &'static str {
70	env!("CARGO_PKG_VERSION")
71}
72
73fn refresh_generated_metadata(data: &mut AddonData) {
74	data.version = current_version().to_string();
75	data.generated_at = chrono::Utc::now();
76}
77
78/// Non-fatal warning from import.
79#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
80pub struct ImportWarning {
81	/// Stable machine-readable warning code.
82	pub code: String,
83	/// Human-readable warning message.
84	pub message: String,
85}
86
87/// Result of an import operation.
88#[derive(Debug, Clone, PartialEq, serde::Serialize)]
89pub struct ImportResult {
90	/// The newly created media entry.
91	pub entry: MediaEntry,
92	/// Non-fatal warnings emitted during import.
93	pub warnings: Vec<ImportWarning>,
94}
95
96/// Options for importing a media file.
97#[derive(Debug, Clone)]
98pub struct ImportOptions {
99	/// Target LibSharedMedia type for the imported file.
100	pub media_type: MediaType,
101	/// Display key used during registration.
102	pub key: String,
103	/// Source file path on the local filesystem.
104	pub source: PathBuf,
105	/// Optional locale names for font assets.
106	pub locales: Vec<String>,
107	/// Optional user-defined tags.
108	pub tags: Vec<String>,
109	/// When `true`, importing a duplicate key fails instead of coexisting.
110	pub reject_duplicates: bool,
111}
112
113impl ImportOptions {
114	/// Create a new import configuration with sane defaults.
115	///
116	/// Defaults:
117	/// - `locales = []`
118	/// - `tags = []`
119	/// - `reject_duplicates = true`
120	pub fn new(media_type: MediaType, key: impl Into<String>, source: impl Into<PathBuf>) -> Self {
121		Self {
122			media_type,
123			key: key.into(),
124			source: source.into(),
125			locales: Vec::new(),
126			tags: Vec::new(),
127			reject_duplicates: true,
128		}
129	}
130}
131
132/// Options for updating an existing entry.
133#[derive(Debug, Clone, PartialEq, Eq, Default)]
134pub struct UpdateOptions {
135	/// Optional replacement display key.
136	pub key: Option<String>,
137	/// Optional replacement locale set for font entries.
138	pub locales: Option<Vec<String>>,
139	/// Optional replacement tag set.
140	pub tags: Option<Vec<String>>,
141}
142
143/// Result of a remove operation.
144#[derive(Debug, Clone, PartialEq, serde::Serialize)]
145pub struct RemovedEntry {
146	/// The entry that was removed from the registry.
147	pub entry: MediaEntry,
148	/// Absolute path of the deleted media file.
149	pub deleted_file: PathBuf,
150}
151
152/// Initialize a LibSharedMedia-compatible addon directory.
153///
154/// This function creates the media subdirectories, initializes `data.lua` when
155/// missing, and re-deploys the static `loader.lua` and `.toc` templates.
156///
157/// Existing `data.lua` content is preserved.
158pub fn ensure_addon_dir(addon_dir: &Path, max_backups: u32) -> Result<AddonData, Error> {
159	// Create directory structure
160	std::fs::create_dir_all(addon_dir).map_err(|e| Error::Io {
161		source: e,
162		path: addon_dir.to_path_buf(),
163	})?;
164	for sub in MEDIA_SUBDIRS {
165		let dir = addon_dir.join("media").join(sub);
166		std::fs::create_dir_all(&dir).map_err(|e| Error::Io { source: e, path: dir })?;
167	}
168
169	// Write data.lua if missing
170	let data = if !addon_dir.join("data.lua").exists() {
171		let data = AddonData::empty(current_version());
172		lua_io::write_data(addon_dir, &data, max_backups)?;
173		data
174	} else {
175		lua_io::read_data(addon_dir)?
176	};
177
178	// Always deploy templates (they're static, overwrite is fine)
179	template::deploy_templates(addon_dir)?;
180
181	Ok(data)
182}
183
184/// Read the current addon registry from `data.lua`.
185pub fn read_data(addon_dir: &Path) -> Result<AddonData, Error> {
186	lua_io::read_data(addon_dir)
187}
188
189/// Import a media file into the addon registry.
190///
191/// This is a one-shot atomic operation: read `data.lua`, convert or copy the
192/// asset referenced by [`ImportOptions::source`], append the new entry, and
193/// write `data.lua` back to disk.
194///
195/// The addon directory and static templates are automatically created or
196/// refreshed before the import proceeds.
197pub fn import_media(addon_dir: &Path, opts: ImportOptions, max_backups: u32) -> Result<ImportResult, Error> {
198	let mut data = ensure_addon_dir(addon_dir, max_backups)?;
199	let source = &opts.source;
200
201	// Validate file size
202	let file_size = std::fs::metadata(source)
203		.map_err(|e| Error::Io {
204			source: e,
205			path: source.to_path_buf(),
206		})?
207		.len();
208	let max_size = match opts.media_type {
209		MediaType::Statusbar | MediaType::Background | MediaType::Border => MAX_IMAGE_SIZE,
210		MediaType::Font => MAX_FONT_SIZE,
211		MediaType::Sound => MAX_AUDIO_SIZE,
212	};
213	if file_size > max_size {
214		return Err(Error::FileTooLarge {
215			path: source.to_path_buf(),
216			actual: file_size,
217			max: max_size,
218		});
219	}
220
221	// Enforce key uniqueness when duplicate rejection is enabled.
222	if opts.reject_duplicates
223		&& let Some(existing) = find_by_key(&data, opts.media_type, &opts.key)
224	{
225		return Err(Error::DuplicateKey {
226			r#type: opts.media_type,
227			key: opts.key,
228			existing_id: existing.id,
229		});
230	}
231
232	// Validate that the input extension is accepted for the target media type.
233	let ext = source
234		.extension()
235		.and_then(|e| e.to_str())
236		.map(|e| format!(".{e}"))
237		.unwrap_or_default()
238		.to_lowercase();
239	if !opts.media_type.accepted_extensions().contains(&ext.as_str()) {
240		return Err(Error::UnsupportedFormat {
241			target_type: opts.media_type,
242			extension: ext,
243		});
244	}
245
246	// Build the normalized addon-relative output path.
247	let file_stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
248	let sanitized = sanitize_filename(file_stem);
249	let output_ext = if ext == ".blp" {
250		".blp"
251	} else {
252		opts.media_type.output_extension()
253	};
254	let rel_path = build_unique_relative_path(&data, addon_dir, opts.media_type, &sanitized, &ext, output_ext);
255	let output_path = addon_dir.join(&rel_path);
256
257	if let Some(parent) = output_path.parent() {
258		std::fs::create_dir_all(parent).map_err(|e| Error::Io {
259			source: e,
260			path: parent.to_path_buf(),
261		})?;
262	}
263
264	let mut warnings: Vec<ImportWarning> = Vec::new();
265	let metadata: Option<EntryMetadata>;
266
267	match opts.media_type {
268		MediaType::Statusbar | MediaType::Background | MediaType::Border => {
269			let result = if ext == ".blp" {
270				std::fs::copy(source, &output_path).map_err(|e| Error::Io {
271					source: e,
272					path: source.to_path_buf(),
273				})?;
274				let dynamic = converter::blp::read_blp(source)?;
275				converter::image::ImageConvertResult {
276					width: dynamic.width(),
277					height: dynamic.height(),
278					original_width: dynamic.width(),
279					original_height: dynamic.height(),
280					was_resized: false,
281				}
282			} else {
283				converter::image::convert_to_tga(source, &output_path)?
284			};
285			if result.was_resized {
286				warnings.push(ImportWarning {
287					code: "image_resized".into(),
288					message: format!(
289						"Resized from {}x{} to {}x{}",
290						result.original_width, result.original_height, result.width, result.height
291					),
292				});
293			}
294			if ext == ".jpg" || ext == ".jpeg" {
295				warnings.push(ImportWarning {
296					code: "jpeg_no_alpha".into(),
297					message: "JPEG does not support transparency. Consider using PNG.".into(),
298				});
299			}
300			metadata = Some(EntryMetadata {
301				image_width: Some(result.width),
302				image_height: Some(result.height),
303				..Default::default()
304			});
305		}
306		MediaType::Font => {
307			converter::font::validate_font(source)?;
308			let font_meta = converter::font::extract_font_metadata(source)?;
309			let locales = if opts.locales.is_empty() {
310				converter::font::DEFAULT_LOCALES.iter().map(|s| s.to_string()).collect()
311			} else {
312				converter::font::validate_locale_names(&opts.locales.iter().map(|s| s.as_str()).collect::<Vec<_>>())?
313			};
314			std::fs::copy(source, &output_path).map_err(|e| Error::Io {
315				source: e,
316				path: source.to_path_buf(),
317			})?;
318			metadata = Some(EntryMetadata {
319				font_family: Some(font_meta.family_name),
320				font_style: Some(font_meta.style_name),
321				font_is_monospace: Some(font_meta.is_monospace),
322				font_num_glyphs: Some(font_meta.num_glyphs),
323				locales,
324				..Default::default()
325			});
326		}
327		MediaType::Sound => {
328			if ext == ".ogg" {
329				std::fs::copy(source, &output_path).map_err(|e| Error::Io {
330					source: e,
331					path: source.to_path_buf(),
332				})?;
333				let audio_meta = converter::audio::probe_audio(&output_path)?;
334				metadata = Some(EntryMetadata {
335					audio_duration_secs: Some(audio_meta.duration_secs),
336					audio_sample_rate: Some(audio_meta.sample_rate),
337					audio_channels: Some(audio_meta.channels),
338					..Default::default()
339				});
340			} else {
341				let audio_meta = converter::audio::convert_to_ogg(source, &output_path)?;
342				metadata = Some(EntryMetadata {
343					audio_duration_secs: Some(audio_meta.duration_secs),
344					audio_sample_rate: Some(audio_meta.sample_rate),
345					audio_channels: Some(audio_meta.channels),
346					..Default::default()
347				});
348			}
349		}
350	}
351
352	// Checksum
353	let file_bytes = std::fs::read(&output_path).map_err(|e| Error::Io {
354		source: e,
355		path: output_path.clone(),
356	})?;
357	let digest = sha2::Sha256::digest(&file_bytes);
358	let checksum = format!("sha256:{:x}", digest);
359
360	let entry = MediaEntry {
361		id: uuid::Uuid::new_v4(),
362		media_type: opts.media_type,
363		key: opts.key,
364		file: rel_path,
365		original_name: source.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()),
366		imported_at: chrono::Utc::now(),
367		checksum: Some(checksum),
368		metadata,
369		tags: opts.tags,
370	};
371
372	data.entries.push(entry.clone());
373	refresh_generated_metadata(&mut data);
374	lua_io::write_data(addon_dir, &data, max_backups)?;
375
376	Ok(ImportResult { entry, warnings })
377}
378
379/// Remove a media entry: ensure addon exists → delete file → remove entry → write data.lua.
380pub fn remove_media(addon_dir: &Path, id: &uuid::Uuid, max_backups: u32) -> Result<RemovedEntry, Error> {
381	let mut data = ensure_addon_dir(addon_dir, max_backups)?;
382
383	let idx = data
384		.entries
385		.iter()
386		.position(|e| &e.id == id)
387		.ok_or(Error::EntryNotFound(*id))?;
388
389	let entry = data.entries.remove(idx);
390	let file_path = addon_dir.join(&entry.file);
391	let deleted_file = file_path.clone();
392
393	if file_path.exists() {
394		std::fs::remove_file(&file_path).map_err(|e| Error::Io {
395			source: e,
396			path: file_path,
397		})?;
398	}
399
400	refresh_generated_metadata(&mut data);
401	lua_io::write_data(addon_dir, &data, max_backups)?;
402
403	Ok(RemovedEntry { entry, deleted_file })
404}
405
406/// Update entry metadata: ensure addon exists → modify in memory → write data.lua.
407pub fn update_media(
408	addon_dir: &Path,
409	id: &uuid::Uuid,
410	opts: UpdateOptions,
411	max_backups: u32,
412) -> Result<MediaEntry, Error> {
413	let mut data = ensure_addon_dir(addon_dir, max_backups)?;
414
415	let idx = data
416		.entries
417		.iter()
418		.position(|e| &e.id == id)
419		.ok_or(Error::EntryNotFound(*id))?;
420
421	// Reject collisions when renaming to an existing key of the same media type.
422	if let Some(ref new_key) = opts.key
423		&& new_key != &data.entries[idx].key
424		&& let Some(dup) = find_by_key(&data, data.entries[idx].media_type, new_key)
425	{
426		return Err(Error::DuplicateKey {
427			r#type: data.entries[idx].media_type,
428			key: new_key.clone(),
429			existing_id: dup.id,
430		});
431	}
432
433	let entry = &mut data.entries[idx];
434	if let Some(ref new_key) = opts.key {
435		entry.key = new_key.clone();
436	}
437	if let Some(ref locales) = opts.locales {
438		if entry.media_type != MediaType::Font {
439			return Err(Error::InvalidLocale(
440				"Locale masks are only supported for font entries".to_string(),
441			));
442		}
443
444		let validated_locales = if locales.is_empty() {
445			Vec::new()
446		} else {
447			crate::converter::font::validate_locale_names(&locales.iter().map(|s| s.as_str()).collect::<Vec<_>>())?
448		};
449
450		if let Some(ref mut meta) = entry.metadata {
451			meta.locales = validated_locales;
452		} else if !validated_locales.is_empty() {
453			entry.metadata = Some(EntryMetadata {
454				locales: validated_locales,
455				..Default::default()
456			});
457		}
458	}
459	if let Some(ref tags) = opts.tags {
460		entry.tags = tags.clone();
461	}
462
463	refresh_generated_metadata(&mut data);
464	lua_io::write_data(addon_dir, &data, max_backups)?;
465
466	Ok(data.entries[idx].clone())
467}
468
469fn find_by_key<'a>(data: &'a AddonData, media_type: MediaType, key: &str) -> Option<&'a MediaEntry> {
470	data.entries.iter().find(|e| e.media_type == media_type && e.key == key)
471}
472
473fn build_unique_relative_path(
474	data: &AddonData,
475	addon_dir: &Path,
476	media_type: MediaType,
477	base_name: &str,
478	input_ext: &str,
479	output_ext: &str,
480) -> String {
481	let folder = media_type.folder_name();
482	let fallback_ext = input_ext.trim_start_matches('.');
483
484	for index in 0.. {
485		let stem = if index == 0 {
486			base_name.to_string()
487		} else {
488			format!("{base_name}-{index}")
489		};
490
491		let file_name = if output_ext.is_empty() {
492			format!("{stem}.{fallback_ext}")
493		} else {
494			format!("{stem}{output_ext}")
495		};
496
497		let rel_path = format!("media/{folder}/{file_name}");
498		let path_used_by_entry = data.entries.iter().any(|entry| entry.file == rel_path);
499		let path_exists_on_disk = addon_dir.join(&rel_path).exists();
500
501		if !path_used_by_entry && !path_exists_on_disk {
502			return rel_path;
503		}
504	}
505
506	unreachable!("unique media output path generation should always terminate")
507}
508
509#[cfg(test)]
510mod tests {
511	use super::*;
512	use tempfile::TempDir;
513
514	/// Create a minimal 1x1 RGBA PNG using the `image` crate (guaranteed valid).
515	fn create_test_png(path: &std::path::Path) {
516		let img =
517			image::DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel(1, 1, image::Rgba([255, 255, 255, 255])));
518		let mut buf = std::io::Cursor::new(Vec::new());
519		img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
520		std::fs::write(path, buf.into_inner()).unwrap();
521	}
522
523	fn import_statusbar(addon_dir: &std::path::Path, source: &std::path::Path, key: &str) -> ImportResult {
524		import_media(
525			addon_dir,
526			ImportOptions::new(MediaType::Statusbar, key, source),
527			DEFAULT_MAX_BACKUPS,
528		)
529		.unwrap()
530	}
531
532	fn normalize_data_lua_snapshot(content: &str) -> String {
533		let mut lines = Vec::new();
534		for line in content.replace("\r\n", "\n").lines() {
535			let trimmed = line.trim_start();
536			let indent = &line[..line.len() - trimmed.len()];
537			let normalized = if trimmed.starts_with("-- Generated: ") {
538				format!("{indent}Generated: <GENERATED_AT>")
539			} else if trimmed.starts_with("generated_at = ") || trimmed.starts_with("imported_at = ") {
540				let ts_normalized = strip_timestamp_value(trimmed);
541				format!("{indent}{ts_normalized}")
542			} else if trimmed.starts_with("id = ") {
543				format!("{indent}id = \"<UUID>\"")
544			} else if trimmed.starts_with("checksum = ") {
545				format!("{indent}checksum = \"<CHECKSUM>\"")
546			} else {
547				line.to_string()
548			};
549			lines.push(normalized);
550		}
551		lines.join("\n")
552	}
553
554	fn strip_timestamp_value(s: &str) -> String {
555		let mut result = s.to_string();
556		while let Some(pos) = result.find("\"20") {
557			let rest = &result[pos + 1..];
558			if let Some(end) = rest.find('"') {
559				result = format!("{}<TS>\"{}", &result[..pos + 1], &rest[end + 1..]);
560			} else {
561				break;
562			}
563		}
564		result
565	}
566
567	fn read_data_lua_snapshot(addon_dir: &std::path::Path) -> String {
568		let content = std::fs::read_to_string(addon_dir.join("data.lua")).unwrap();
569		normalize_data_lua_snapshot(&content)
570	}
571
572	#[test]
573	fn test_ensure_creates_fresh_addon() {
574		let dir = TempDir::new().unwrap();
575		let addon_dir = dir.path().join("TestAddon");
576
577		let data = ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
578
579		assert!(addon_dir.join("data.lua").exists());
580		assert!(addon_dir.join("loader.lua").exists());
581		assert!(addon_dir.join("TestAddon.toc").exists());
582		assert!(addon_dir.join("media").join("statusbar").is_dir());
583		assert!(addon_dir.join("media").join("background").is_dir());
584		assert!(addon_dir.join("media").join("border").is_dir());
585		assert!(addon_dir.join("media").join("font").is_dir());
586		assert!(addon_dir.join("media").join("sound").is_dir());
587		assert_eq!(data.schema_version, crate::SCHEMA_VERSION);
588		assert!(data.entries.is_empty());
589	}
590
591	#[test]
592	fn test_ensure_is_idempotent() {
593		let dir = TempDir::new().unwrap();
594		let addon_dir = dir.path().join("TestAddon");
595
596		let data1 = ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
597		let data2 = ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
598
599		// Second call reads existing data.lua — version and schema should match
600		assert_eq!(data1.version, data2.version);
601		assert_eq!(data1.schema_version, data2.schema_version);
602		// generated_at may differ in nanosecond precision (write truncates to seconds)
603		// but the data should be semantically the same
604		assert_eq!(data1.entries.len(), data2.entries.len());
605	}
606
607	#[test]
608	fn test_ensure_preserves_existing_data() {
609		let dir = TempDir::new().unwrap();
610		let addon_dir = dir.path().join("TestAddon");
611
612		// Create addon with initial data
613		let data1 = ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
614		assert_eq!(data1.entries.len(), 0);
615
616		// Manually inject an entry via write_data to simulate pre-existing data
617		let mut modified = data1.clone();
618		modified.entries.push(MediaEntry {
619			id: uuid::Uuid::new_v4(),
620			media_type: MediaType::Statusbar,
621			key: "Pre-existing".into(),
622			file: "media/statusbar/pre.tga".into(),
623			original_name: None,
624			imported_at: chrono::Utc::now(),
625			checksum: None,
626			metadata: None,
627			tags: vec![],
628		});
629		crate::lua_io::write_data(&addon_dir, &modified, DEFAULT_MAX_BACKUPS).unwrap();
630
631		// ensure_addon_dir should read back the existing data
632		let data2 = ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
633		assert_eq!(data2.entries.len(), 1);
634		assert_eq!(data2.entries[0].key, "Pre-existing");
635		assert_eq!(data2.version, env!("CARGO_PKG_VERSION"));
636	}
637
638	#[test]
639	fn test_import_image_creates_entry_and_file() {
640		let dir = TempDir::new().unwrap();
641		let addon_dir = dir.path().join("TestAddon");
642		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
643
644		// Create a valid PNG source
645		let source = dir.path().join("test.png");
646		create_test_png(&source);
647
648		let opts = ImportOptions::new(MediaType::Statusbar, "Test Bar", &source);
649		let result = import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS).unwrap();
650
651		assert_eq!(result.entry.key, "Test Bar");
652		assert_eq!(result.entry.media_type, MediaType::Statusbar);
653		assert!(result.entry.checksum.is_some());
654		assert!(result.entry.metadata.is_some());
655		assert!(result.entry.metadata.as_ref().unwrap().image_width.is_some());
656
657		// File should exist on disk
658		assert!(addon_dir.join(&result.entry.file).exists());
659
660		// data.lua should contain the entry
661		let data = read_data(&addon_dir).unwrap();
662		assert_eq!(data.entries.len(), 1);
663		assert_eq!(data.entries[0].key, "Test Bar");
664		assert_eq!(data.version, env!("CARGO_PKG_VERSION"));
665	}
666
667	#[test]
668	fn test_import_rejects_duplicate_key() {
669		let dir = TempDir::new().unwrap();
670		let addon_dir = dir.path().join("TestAddon");
671		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
672
673		let source = dir.path().join("test.png");
674		create_test_png(&source);
675
676		let opts = ImportOptions::new(MediaType::Statusbar, "Dupe", &source);
677		import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS).unwrap();
678
679		// Second import with same key should fail
680		let opts2 = ImportOptions::new(MediaType::Statusbar, "Dupe", &source);
681		let result = import_media(&addon_dir, opts2, DEFAULT_MAX_BACKUPS);
682		assert!(result.is_err());
683		match result.unwrap_err() {
684			Error::DuplicateKey { r#type, key, .. } => {
685				assert_eq!(r#type, MediaType::Statusbar);
686				assert_eq!(key, "Dupe");
687			}
688			other => panic!("Expected DuplicateKey, got: {other}"),
689		}
690	}
691
692	#[test]
693	fn test_import_rejects_invalid_extension() {
694		let dir = TempDir::new().unwrap();
695		let addon_dir = dir.path().join("TestAddon");
696		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
697
698		let source = dir.path().join("test.xyz");
699		std::fs::write(&source, b"not an image").unwrap();
700
701		let opts = ImportOptions::new(MediaType::Statusbar, "Bad", &source);
702		let result = import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS);
703		assert!(result.is_err());
704		match result.unwrap_err() {
705			Error::UnsupportedFormat { extension, .. } => {
706				assert_eq!(extension, ".xyz");
707			}
708			other => panic!("Expected UnsupportedFormat, got: {other}"),
709		}
710	}
711
712	#[test]
713	fn test_import_missing_source_file() {
714		let dir = TempDir::new().unwrap();
715		let addon_dir = dir.path().join("TestAddon");
716		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
717
718		let source = dir.path().join("nonexistent.png");
719		let opts = ImportOptions::new(MediaType::Statusbar, "Missing", &source);
720		let result = import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS);
721		assert!(result.is_err());
722	}
723
724	#[test]
725	fn test_import_auto_bootstraps_missing_addon_dir() {
726		let dir = TempDir::new().unwrap();
727		let addon_dir = dir.path().join("TestAddon");
728
729		let source = dir.path().join("bootstrap.png");
730		create_test_png(&source);
731
732		let result = import_media(
733			&addon_dir,
734			ImportOptions::new(MediaType::Statusbar, "Bootstrap", &source),
735			DEFAULT_MAX_BACKUPS,
736		)
737		.unwrap();
738
739		assert_eq!(result.entry.key, "Bootstrap");
740		assert!(addon_dir.join("data.lua").exists());
741		assert!(addon_dir.join("loader.lua").exists());
742		assert!(addon_dir.join("TestAddon.toc").exists());
743		assert!(addon_dir.join(&result.entry.file).exists());
744	}
745
746	#[test]
747	fn test_import_overwrite_allows_duplicate() {
748		let dir = TempDir::new().unwrap();
749		let addon_dir = dir.path().join("TestAddon");
750		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
751
752		let source = dir.path().join("test.png");
753		create_test_png(&source);
754
755		let mut opts = ImportOptions::new(MediaType::Statusbar, "Same", &source);
756		opts.reject_duplicates = true;
757		import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS).unwrap();
758
759		// With reject_duplicates = false, should succeed
760		let mut opts2 = ImportOptions::new(MediaType::Statusbar, "Same", &source);
761		opts2.reject_duplicates = false;
762		let result = import_media(&addon_dir, opts2, DEFAULT_MAX_BACKUPS);
763		assert!(result.is_ok());
764	}
765
766	#[test]
767	fn test_import_avoids_file_name_collisions() {
768		let dir = TempDir::new().unwrap();
769		let addon_dir = dir.path().join("TestAddon");
770		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
771
772		let source_a = dir.path().join("same-name.png");
773		let source_b_dir = dir.path().join("nested");
774		std::fs::create_dir_all(&source_b_dir).unwrap();
775		let source_b = source_b_dir.join("same-name.png");
776		create_test_png(&source_a);
777		create_test_png(&source_b);
778
779		let a = import_statusbar(&addon_dir, &source_a, "Alpha");
780		let b = import_statusbar(&addon_dir, &source_b, "Beta");
781
782		assert_ne!(a.entry.file, b.entry.file);
783		assert!(addon_dir.join(&a.entry.file).exists());
784		assert!(addon_dir.join(&b.entry.file).exists());
785
786		let data = read_data(&addon_dir).unwrap();
787		assert_eq!(data.entries.len(), 2);
788	}
789
790	#[test]
791	fn test_remove_deletes_entry_and_file() {
792		let dir = TempDir::new().unwrap();
793		let addon_dir = dir.path().join("TestAddon");
794		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
795
796		let source = dir.path().join("test.png");
797		create_test_png(&source);
798
799		let opts = ImportOptions::new(MediaType::Statusbar, "ToRemove", &source);
800		let entry_id = import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS).unwrap().entry.id;
801
802		let file_path = addon_dir.join("media/statusbar/test.tga");
803		assert!(file_path.exists());
804
805		let removed = remove_media(&addon_dir, &entry_id, DEFAULT_MAX_BACKUPS).unwrap();
806		assert_eq!(removed.entry.key, "ToRemove");
807		assert!(!file_path.exists());
808
809		// data.lua should be empty now
810		let data = read_data(&addon_dir).unwrap();
811		assert!(data.entries.is_empty());
812	}
813
814	#[test]
815	fn test_remove_nonexistent_id() {
816		let dir = TempDir::new().unwrap();
817		let addon_dir = dir.path().join("TestAddon");
818		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
819
820		let fake_id = uuid::Uuid::new_v4();
821		let result = remove_media(&addon_dir, &fake_id, DEFAULT_MAX_BACKUPS);
822		assert!(result.is_err());
823		match result.unwrap_err() {
824			Error::EntryNotFound(id) => assert_eq!(id, fake_id),
825			other => panic!("Expected EntryNotFound, got: {other}"),
826		}
827	}
828
829	#[test]
830	fn test_remove_auto_bootstraps_missing_addon_dir() {
831		let dir = TempDir::new().unwrap();
832		let addon_dir = dir.path().join("TestAddon");
833		let fake_id = uuid::Uuid::new_v4();
834
835		let result = remove_media(&addon_dir, &fake_id, DEFAULT_MAX_BACKUPS);
836		assert!(result.is_err());
837		match result.unwrap_err() {
838			Error::EntryNotFound(id) => assert_eq!(id, fake_id),
839			other => panic!("Expected EntryNotFound, got: {other}"),
840		}
841
842		assert!(addon_dir.join("data.lua").exists());
843		assert!(addon_dir.join("loader.lua").exists());
844		assert!(addon_dir.join("TestAddon.toc").exists());
845	}
846
847	#[test]
848	fn test_remove_succeeds_when_file_already_deleted() {
849		let dir = TempDir::new().unwrap();
850		let addon_dir = dir.path().join("TestAddon");
851		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
852
853		let source = dir.path().join("test.png");
854		create_test_png(&source);
855
856		let opts = ImportOptions::new(MediaType::Statusbar, "Ghost", &source);
857		let entry_id = import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS).unwrap().entry.id;
858
859		// Manually delete the file before calling remove
860		let file_path = addon_dir.join("media/statusbar/test.tga");
861		assert!(file_path.exists());
862		std::fs::remove_file(&file_path).unwrap();
863
864		// Remove should still succeed (just can't delete the already-missing file)
865		let removed = remove_media(&addon_dir, &entry_id, DEFAULT_MAX_BACKUPS).unwrap();
866		assert_eq!(removed.entry.key, "Ghost");
867
868		let data = read_data(&addon_dir).unwrap();
869		assert!(data.entries.is_empty());
870	}
871
872	#[test]
873	fn test_update_key() {
874		let dir = TempDir::new().unwrap();
875		let addon_dir = dir.path().join("TestAddon");
876		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
877
878		let source = dir.path().join("test.png");
879		create_test_png(&source);
880
881		let opts = ImportOptions::new(MediaType::Statusbar, "OldKey", &source);
882		let entry_id = import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS).unwrap().entry.id;
883
884		let updated = update_media(
885			&addon_dir,
886			&entry_id,
887			UpdateOptions {
888				key: Some("NewKey".into()),
889				locales: None,
890				tags: None,
891			},
892			DEFAULT_MAX_BACKUPS,
893		)
894		.unwrap();
895
896		assert_eq!(updated.key, "NewKey");
897
898		// Persisted to data.lua
899		let data = read_data(&addon_dir).unwrap();
900		assert_eq!(data.entries[0].key, "NewKey");
901	}
902
903	#[test]
904	fn test_update_tags() {
905		let dir = TempDir::new().unwrap();
906		let addon_dir = dir.path().join("TestAddon");
907		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
908
909		let source = dir.path().join("test.png");
910		create_test_png(&source);
911
912		let opts = ImportOptions::new(MediaType::Statusbar, "TagMe", &source);
913		let entry_id = import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS).unwrap().entry.id;
914
915		let updated = update_media(
916			&addon_dir,
917			&entry_id,
918			UpdateOptions {
919				key: None,
920				locales: None,
921				tags: Some(vec!["a".into(), "b".into()]),
922			},
923			DEFAULT_MAX_BACKUPS,
924		)
925		.unwrap();
926
927		assert_eq!(updated.tags, vec!["a", "b"]);
928	}
929
930	#[cfg(target_os = "windows")]
931	#[test]
932	fn test_update_font_locales_validates_names() {
933		let dir = TempDir::new().unwrap();
934		let addon_dir = dir.path().join("TestAddon");
935		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
936
937		let source = dir.path().join("font.ttf");
938		std::fs::copy(r"C:\Windows\Fonts\arial.ttf", &source).unwrap();
939
940		let mut opts = ImportOptions::new(MediaType::Font, "Body Font", &source);
941		opts.locales = vec!["western".into()];
942		let entry_id = import_media(&addon_dir, opts, DEFAULT_MAX_BACKUPS).unwrap().entry.id;
943
944		let result = update_media(
945			&addon_dir,
946			&entry_id,
947			UpdateOptions {
948				key: None,
949				locales: Some(vec!["bad-locale".into()]),
950				tags: None,
951			},
952			DEFAULT_MAX_BACKUPS,
953		);
954		assert!(result.is_err());
955		match result.unwrap_err() {
956			Error::InvalidLocale(msg) => assert!(msg.contains("Invalid locale names")),
957			other => panic!("Expected InvalidLocale, got: {other}"),
958		}
959	}
960
961	#[test]
962	fn test_update_non_font_locales_rejected() {
963		let dir = TempDir::new().unwrap();
964		let addon_dir = dir.path().join("TestAddon");
965		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
966
967		let source = dir.path().join("test.png");
968		create_test_png(&source);
969		let entry_id = import_statusbar(&addon_dir, &source, "Statusbar").entry.id;
970
971		let result = update_media(
972			&addon_dir,
973			&entry_id,
974			UpdateOptions {
975				key: None,
976				locales: Some(vec!["western".into()]),
977				tags: None,
978			},
979			DEFAULT_MAX_BACKUPS,
980		);
981		assert!(result.is_err());
982		match result.unwrap_err() {
983			Error::InvalidLocale(msg) => assert!(msg.contains("only supported for font entries")),
984			other => panic!("Expected InvalidLocale, got: {other}"),
985		}
986	}
987
988	#[test]
989	fn test_update_rejects_duplicate_key() {
990		let dir = TempDir::new().unwrap();
991		let addon_dir = dir.path().join("TestAddon");
992		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
993
994		let source = dir.path().join("test.png");
995		create_test_png(&source);
996
997		// Import two different entries
998		let opts1 = ImportOptions::new(MediaType::Statusbar, "Alpha", &source);
999		let id1 = import_media(&addon_dir, opts1, DEFAULT_MAX_BACKUPS).unwrap().entry.id;
1000
1001		let source2 = dir.path().join("test2.png");
1002		create_test_png(&source2);
1003		let opts2 = ImportOptions::new(MediaType::Statusbar, "Beta", &source2);
1004		import_media(&addon_dir, opts2, DEFAULT_MAX_BACKUPS).unwrap();
1005
1006		// Try to rename Alpha → Beta (duplicate)
1007		let result = update_media(
1008			&addon_dir,
1009			&id1,
1010			UpdateOptions {
1011				key: Some("Beta".into()),
1012				locales: None,
1013				tags: None,
1014			},
1015			DEFAULT_MAX_BACKUPS,
1016		);
1017
1018		assert!(result.is_err());
1019		match result.unwrap_err() {
1020			Error::DuplicateKey { key, .. } => assert_eq!(key, "Beta"),
1021			other => panic!("Expected DuplicateKey, got: {other}"),
1022		}
1023	}
1024
1025	#[test]
1026	fn test_update_nonexistent_id() {
1027		let dir = TempDir::new().unwrap();
1028		let addon_dir = dir.path().join("TestAddon");
1029		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
1030
1031		let fake_id = uuid::Uuid::new_v4();
1032		let result = update_media(
1033			&addon_dir,
1034			&fake_id,
1035			UpdateOptions {
1036				key: Some("X".into()),
1037				locales: None,
1038				tags: None,
1039			},
1040			DEFAULT_MAX_BACKUPS,
1041		);
1042		assert!(result.is_err());
1043	}
1044
1045	#[test]
1046	fn test_update_auto_bootstraps_missing_addon_dir() {
1047		let dir = TempDir::new().unwrap();
1048		let addon_dir = dir.path().join("TestAddon");
1049		let fake_id = uuid::Uuid::new_v4();
1050
1051		let result = update_media(
1052			&addon_dir,
1053			&fake_id,
1054			UpdateOptions {
1055				key: Some("Bootstrap Update".into()),
1056				locales: None,
1057				tags: None,
1058			},
1059			DEFAULT_MAX_BACKUPS,
1060		);
1061		assert!(result.is_err());
1062		match result.unwrap_err() {
1063			Error::EntryNotFound(id) => assert_eq!(id, fake_id),
1064			other => panic!("Expected EntryNotFound, got: {other}"),
1065		}
1066
1067		assert!(addon_dir.join("data.lua").exists());
1068		assert!(addon_dir.join("loader.lua").exists());
1069		assert!(addon_dir.join("TestAddon.toc").exists());
1070	}
1071
1072	#[test]
1073	fn test_read_from_nonexistent_dir() {
1074		let dir = TempDir::new().unwrap();
1075		let result = read_data(dir.path());
1076		assert!(result.is_err());
1077	}
1078
1079	#[test]
1080	fn test_full_lifecycle() {
1081		let dir = TempDir::new().unwrap();
1082		let addon_dir = dir.path().join("TestAddon");
1083
1084		// 1. Init
1085		let data = ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
1086		assert_eq!(data.entries.len(), 0);
1087
1088		// 2. Import
1089		let source = dir.path().join("a.png");
1090		create_test_png(&source);
1091		let id = import_media(
1092			&addon_dir,
1093			ImportOptions::new(MediaType::Statusbar, "A", &source),
1094			DEFAULT_MAX_BACKUPS,
1095		)
1096		.unwrap()
1097		.entry
1098		.id;
1099
1100		let source2 = dir.path().join("b.png");
1101		create_test_png(&source2);
1102		let id2 = import_media(
1103			&addon_dir,
1104			ImportOptions::new(MediaType::Statusbar, "B", &source2),
1105			DEFAULT_MAX_BACKUPS,
1106		)
1107		.unwrap()
1108		.entry
1109		.id;
1110
1111		// 3. Read back
1112		let data = read_data(&addon_dir).unwrap();
1113		assert_eq!(data.entries.len(), 2);
1114
1115		// 4. Update
1116		let _ = update_media(
1117			&addon_dir,
1118			&id2,
1119			UpdateOptions {
1120				key: Some("B-Renamed".into()),
1121				locales: None,
1122				tags: Some(vec!["renamed".into()]),
1123			},
1124			DEFAULT_MAX_BACKUPS,
1125		)
1126		.unwrap();
1127
1128		// 5. Remove one
1129		let _ = remove_media(&addon_dir, &id, DEFAULT_MAX_BACKUPS).unwrap();
1130
1131		// 6. Verify final state
1132		let data = read_data(&addon_dir).unwrap();
1133		assert_eq!(data.entries.len(), 1);
1134		assert_eq!(data.entries[0].key, "B-Renamed");
1135		assert_eq!(data.entries[0].tags, vec!["renamed"]);
1136		assert_eq!(data.version, env!("CARGO_PKG_VERSION"));
1137	}
1138
1139	#[test]
1140	fn test_data_lua_end_to_end_state_transition_snapshot() {
1141		let dir = TempDir::new().unwrap();
1142		let addon_dir = dir.path().join("TestAddon");
1143
1144		ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
1145		let initial_snapshot = read_data_lua_snapshot(&addon_dir);
1146		assert!(initial_snapshot.contains(&format!("Tool: wow-sharedmedia v{}", env!("CARGO_PKG_VERSION"))));
1147		assert!(initial_snapshot.contains(&format!("version = \"{}\"", env!("CARGO_PKG_VERSION"))));
1148		assert!(!initial_snapshot.contains("Entries:"));
1149		assert!(!initial_snapshot.contains("--[[table:"));
1150
1151		let source = dir.path().join("lifecycle.png");
1152		create_test_png(&source);
1153
1154		let imported = import_media(
1155			&addon_dir,
1156			ImportOptions::new(MediaType::Statusbar, "Lifecycle", &source),
1157			DEFAULT_MAX_BACKUPS,
1158		)
1159		.unwrap();
1160		let after_import = read_data_lua_snapshot(&addon_dir);
1161		assert_ne!(initial_snapshot, after_import);
1162		assert!(!after_import.contains("Entries:"));
1163		assert!(after_import.contains("key = \"Lifecycle\""));
1164		assert!(after_import.contains("file = \"media/statusbar/lifecycle.tga\""));
1165		assert!(after_import.contains("image_height = 1"));
1166		assert!(after_import.contains("image_width = 1"));
1167
1168		update_media(
1169			&addon_dir,
1170			&imported.entry.id,
1171			UpdateOptions {
1172				key: Some("Lifecycle Updated".into()),
1173				locales: None,
1174				tags: Some(vec!["golden".into(), "stateful".into()]),
1175			},
1176			DEFAULT_MAX_BACKUPS,
1177		)
1178		.unwrap();
1179		let after_update = read_data_lua_snapshot(&addon_dir);
1180		assert_ne!(after_import, after_update);
1181		assert!(after_update.contains("key = \"Lifecycle Updated\""));
1182		assert!(!after_update.contains("key = \"Lifecycle\""));
1183		assert!(after_update.contains("tags = {"));
1184
1185		remove_media(&addon_dir, &imported.entry.id, DEFAULT_MAX_BACKUPS).unwrap();
1186		let after_remove = read_data_lua_snapshot(&addon_dir);
1187		assert_eq!(initial_snapshot, after_remove);
1188	}
1189
1190	#[test]
1191	fn test_sanitize_chinese_preserved() {
1192		assert_eq!(sanitize_filename("中文材质.tga"), "中文材质.tga");
1193	}
1194
1195	#[test]
1196	fn test_sanitize_special_chars_stripped() {
1197		assert_eq!(sanitize_filename("My Cool Texture!! 2.png"), "my_cool_texture_2.png");
1198	}
1199
1200	#[test]
1201	fn test_sanitize_consecutive_underscores() {
1202		assert_eq!(sanitize_filename("hello___world"), "hello_world");
1203	}
1204
1205	#[test]
1206	fn test_sanitize_empty_string() {
1207		assert_eq!(sanitize_filename(""), "unnamed");
1208		assert_eq!(sanitize_filename("!!!"), "unnamed");
1209	}
1210
1211	#[test]
1212	fn test_sanitize_trimming() {
1213		assert_eq!(sanitize_filename("_hello_"), "hello");
1214	}
1215
1216	#[test]
1217	fn test_sanitize_korean_preserved() {
1218		assert_eq!(sanitize_filename("한글폰트.ttf"), "한글폰트.ttf");
1219	}
1220
1221	#[test]
1222	fn test_sanitize_japanese_preserved() {
1223		assert_eq!(sanitize_filename("フォント.otf"), "フォント.otf");
1224	}
1225}