1use 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
17pub const DEFAULT_MAX_BACKUPS: u32 = 10;
19
20#[inline]
22fn is_cjk_or_hangul(ch: char) -> bool {
23 matches!(ch,
24 '\u{4e00}'..='\u{9fff}' | '\u{3400}'..='\u{4dbf}' | '\u{f900}'..='\u{faff}' | '\u{ac00}'..='\u{d7af}' | '\u{3040}'..='\u{309f}' | '\u{30a0}'..='\u{30ff}' | '\u{31f0}'..='\u{31ff}' )
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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
80pub struct ImportWarning {
81 pub code: String,
83 pub message: String,
85}
86
87#[derive(Debug, Clone, PartialEq, serde::Serialize)]
89pub struct ImportResult {
90 pub entry: MediaEntry,
92 pub warnings: Vec<ImportWarning>,
94}
95
96#[derive(Debug, Clone)]
98pub struct ImportOptions {
99 pub media_type: MediaType,
101 pub key: String,
103 pub source: PathBuf,
105 pub locales: Vec<String>,
107 pub tags: Vec<String>,
109 pub reject_duplicates: bool,
111}
112
113impl ImportOptions {
114 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
134pub struct UpdateOptions {
135 pub key: Option<String>,
137 pub locales: Option<Vec<String>>,
139 pub tags: Option<Vec<String>>,
141}
142
143#[derive(Debug, Clone, PartialEq, serde::Serialize)]
145pub struct RemovedEntry {
146 pub entry: MediaEntry,
148 pub deleted_file: PathBuf,
150}
151
152pub fn ensure_addon_dir(addon_dir: &Path, max_backups: u32) -> Result<AddonData, Error> {
159 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 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 template::deploy_templates(addon_dir)?;
180
181 Ok(data)
182}
183
184pub fn read_data(addon_dir: &Path) -> Result<AddonData, Error> {
186 lua_io::read_data(addon_dir)
187}
188
189pub 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 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 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 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 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 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
379pub 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
406pub 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 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 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 assert_eq!(data1.version, data2.version);
601 assert_eq!(data1.schema_version, data2.schema_version);
602 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 let data1 = ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
614 assert_eq!(data1.entries.len(), 0);
615
616 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 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 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 assert!(addon_dir.join(&result.entry.file).exists());
659
660 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 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 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 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 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 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 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 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 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 let data = ensure_addon_dir(&addon_dir, DEFAULT_MAX_BACKUPS).unwrap();
1086 assert_eq!(data.entries.len(), 0);
1087
1088 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 let data = read_data(&addon_dir).unwrap();
1113 assert_eq!(data.entries.len(), 2);
1114
1115 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 let _ = remove_media(&addon_dir, &id, DEFAULT_MAX_BACKUPS).unwrap();
1130
1131 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}