1pub mod atlas;
9
10pub use atlas::{atlas_report, load_sidecar, resolve_sprite_source, sheet_path};
11pub use atlas::{AtlasUse, SpriteSource};
12
13use std::collections::{HashMap, HashSet};
14use std::path::{Path, PathBuf};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use anyhow::{Context, Result};
19use serde::{Deserialize, Serialize};
20
21pub const MANIFEST_FILE: &str = "assets.pite.toml";
23
24const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
26
27static UID_COUNTER: AtomicU64 = AtomicU64::new(0);
29
30pub trait Importer: Send + Sync {
31 fn match_ext(&self, ext: &str) -> bool;
32 fn import(&self, path: &str) -> Result<()>;
33}
34
35pub struct PngImporter;
36
37impl Importer for PngImporter {
38 fn match_ext(&self, ext: &str) -> bool {
39 ext.eq_ignore_ascii_case("png")
40 }
41
42 fn import(&self, path: &str) -> Result<()> {
43 let bytes = std::fs::read(path).with_context(|| format!("asset not found: {path}"))?;
44 if bytes.len() < PNG_SIG.len() || bytes[..PNG_SIG.len()] != PNG_SIG {
45 anyhow::bail!("not a PNG asset: {path}");
46 }
47 Ok(())
48 }
49}
50
51pub struct WavImporter;
52
53impl Importer for WavImporter {
54 fn match_ext(&self, ext: &str) -> bool {
55 ext.eq_ignore_ascii_case("wav")
56 }
57
58 fn import(&self, path: &str) -> Result<()> {
59 let bytes = std::fs::read(path).with_context(|| format!("asset not found: {path}"))?;
60 if bytes.len() < 12 || bytes[0..4] != *b"RIFF" || bytes[8..12] != *b"WAVE" {
61 anyhow::bail!("not a WAV asset: {path}");
62 }
63 Ok(())
64 }
65}
66
67pub struct OggImporter;
68
69impl Importer for OggImporter {
70 fn match_ext(&self, ext: &str) -> bool {
71 ext.eq_ignore_ascii_case("ogg")
72 }
73
74 fn import(&self, path: &str) -> Result<()> {
75 let bytes = std::fs::read(path).with_context(|| format!("asset not found: {path}"))?;
76 if bytes.len() < 4 || bytes[0..4] != *b"OggS" {
77 anyhow::bail!("not an OGG asset: {path}");
78 }
79 Ok(())
80 }
81}
82
83pub struct Mp3Importer;
84
85impl Importer for Mp3Importer {
86 fn match_ext(&self, ext: &str) -> bool {
87 ext.eq_ignore_ascii_case("mp3")
88 }
89
90 fn import(&self, path: &str) -> Result<()> {
91 let bytes = std::fs::read(path).with_context(|| format!("asset not found: {path}"))?;
92 let is_id3 = bytes.len() >= 3 && &bytes[0..3] == b"ID3";
93 let is_sync = bytes.len() >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0;
94 if !is_id3 && !is_sync {
95 anyhow::bail!("not an MP3 asset: {path}");
96 }
97 Ok(())
98 }
99}
100
101pub struct FlacImporter;
102
103impl Importer for FlacImporter {
104 fn match_ext(&self, ext: &str) -> bool {
105 ext.eq_ignore_ascii_case("flac")
106 }
107
108 fn import(&self, path: &str) -> Result<()> {
109 let bytes = std::fs::read(path).with_context(|| format!("asset not found: {path}"))?;
110 if bytes.len() < 4 || bytes[0..4] != *b"fLaC" {
111 anyhow::bail!("not a FLAC asset: {path}");
112 }
113 Ok(())
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct AssetEntry {
120 pub path: String,
121 pub uid: String,
122 pub hash: String,
123 pub size: u64,
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
128struct ManifestDoc {
129 #[serde(default)]
130 asset: Vec<AssetEntry>,
131}
132
133#[derive(Debug, Default, Clone)]
134pub struct UidRegistry {
135 by_path: HashMap<String, AssetEntry>,
136}
137
138impl UidRegistry {
139 pub fn register(&mut self, path: impl Into<String>, uid: impl Into<String>) {
140 let path = path.into();
141 let uid = uid.into();
142 match self.by_path.get_mut(&path) {
143 Some(entry) => entry.uid = uid,
144 None => {
145 self.by_path.insert(
146 path.clone(),
147 AssetEntry {
148 path,
149 uid,
150 hash: String::new(),
151 size: 0,
152 },
153 );
154 }
155 }
156 }
157
158 pub fn get(&self, path: &str) -> Option<&str> {
159 self.by_path.get(path).map(|e| e.uid.as_str())
160 }
161
162 pub fn path_for_uid(&self, uid: &str) -> Option<&str> {
163 self.by_path
164 .values()
165 .find(|e| e.uid == uid)
166 .map(|e| e.path.as_str())
167 }
168
169 pub fn iter(&self) -> impl Iterator<Item = &AssetEntry> {
170 self.by_path.values()
171 }
172
173 pub fn len(&self) -> usize {
174 self.by_path.len()
175 }
176
177 pub fn is_empty(&self) -> bool {
178 self.by_path.is_empty()
179 }
180}
181
182pub fn default_importers() -> Vec<Box<dyn Importer>> {
183 vec![
184 Box::new(PngImporter),
185 Box::new(WavImporter),
186 Box::new(OggImporter),
187 Box::new(Mp3Importer),
188 Box::new(FlacImporter),
189 ]
190}
191
192fn fnv1a_hex(bytes: &[u8]) -> String {
195 let mut hash: u64 = 14695981039346656037;
196 for b in bytes {
197 hash ^= u64::from(*b);
198 hash = hash.wrapping_mul(1099511628211);
199 }
200 format!("{hash:016x}")
201}
202
203fn new_uid(path: &str) -> String {
206 let nanos = SystemTime::now()
207 .duration_since(UNIX_EPOCH)
208 .map(|d| d.as_nanos())
209 .unwrap_or(0);
210 let count = UID_COUNTER.fetch_add(1, Ordering::SeqCst);
211 let pid = std::process::id();
212 fnv1a_hex(format!("{path}:{nanos}:{pid}:{count}").as_bytes())
213}
214
215#[derive(Debug, Clone)]
217pub struct ScannedFile {
218 pub res_path: String,
219 pub fs_path: PathBuf,
220 pub hash: String,
221 pub size: u64,
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227pub struct ScanReport {
228 pub scanned: usize,
229 pub added: Vec<String>,
230 pub renamed: Vec<(String, String)>,
231 pub removed: Vec<String>,
232 pub changed: Vec<String>,
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237pub struct ReimportReport {
238 pub reimported: Vec<String>,
239 pub failed: Vec<String>,
240 pub refs_updated: Vec<String>,
241}
242
243fn is_skipped_dir(name: &str) -> bool {
245 name == "target" || name == "dist" || name == ".git"
246}
247
248fn res_path_for(root: &Path, fs_path: &Path) -> Result<String> {
250 let rel = fs_path
251 .strip_prefix(root)
252 .with_context(|| format!("path {} outside root {}", fs_path.display(), root.display()))?;
253 let joined = rel
254 .components()
255 .map(|c| c.as_os_str().to_string_lossy().into_owned())
256 .collect::<Vec<_>>()
257 .join("/");
258 Ok(format!("res://{joined}"))
259}
260
261pub fn scan_files(root: &Path) -> Result<Vec<ScannedFile>> {
265 let importers = default_importers();
266 let mut out = Vec::new();
267 let mut stack = vec![root.to_path_buf()];
268 while let Some(dir) = stack.pop() {
269 let entries =
270 std::fs::read_dir(&dir).with_context(|| format!("read dir {}", dir.display()))?;
271 for entry in entries {
272 let entry = entry.with_context(|| format!("read entry in {}", dir.display()))?;
273 let fs_path = entry.path();
274 let name = entry.file_name().to_string_lossy().into_owned();
275 if name.starts_with('.') {
276 continue;
277 }
278 if fs_path.is_dir() {
279 if is_skipped_dir(&name) {
280 continue;
281 }
282 stack.push(fs_path);
283 continue;
284 }
285 if !fs_path.is_file() {
286 continue;
287 }
288 let ext = fs_path
289 .extension()
290 .and_then(|e| e.to_str())
291 .unwrap_or("")
292 .to_lowercase();
293 if !importers.iter().any(|i| i.match_ext(&ext)) {
294 continue;
295 }
296 let bytes = std::fs::read(&fs_path)
297 .with_context(|| format!("read asset {}", fs_path.display()))?;
298 out.push(ScannedFile {
299 res_path: res_path_for(root, &fs_path)?,
300 fs_path,
301 hash: fnv1a_hex(&bytes),
302 size: bytes.len() as u64,
303 });
304 }
305 }
306 out.sort_by(|a, b| a.res_path.cmp(&b.res_path));
307 Ok(out)
308}
309
310pub fn load_registry(root: &Path) -> Result<UidRegistry> {
312 let path = root.join(MANIFEST_FILE);
313 let text = match std::fs::read_to_string(&path) {
314 Ok(text) => text,
315 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(UidRegistry::default()),
316 Err(err) => {
317 return Err(err).with_context(|| format!("read {}", path.display()));
318 }
319 };
320 if text.trim().is_empty() {
321 return Ok(UidRegistry::default());
322 }
323 let doc: ManifestDoc =
324 toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
325 let mut reg = UidRegistry::default();
326 for entry in doc.asset {
327 reg.by_path.insert(entry.path.clone(), entry);
328 }
329 Ok(reg)
330}
331
332pub fn save_registry(root: &Path, reg: &UidRegistry) -> Result<()> {
334 let mut entries: Vec<&AssetEntry> = reg.iter().collect();
335 entries.sort_by(|a, b| a.path.cmp(&b.path));
336 let doc = ManifestDoc {
337 asset: entries.into_iter().cloned().collect(),
338 };
339 let text = toml::to_string(&doc).context("serialize asset manifest")?;
340 std::fs::write(root.join(MANIFEST_FILE), text)
341 .with_context(|| format!("write {}", root.join(MANIFEST_FILE).display()))?;
342 Ok(())
343}
344
345pub fn reconcile(old: &UidRegistry, files: &[ScannedFile]) -> (UidRegistry, ScanReport) {
350 let mut next = UidRegistry::default();
351 let mut added = Vec::new();
352 let mut renamed = Vec::new();
353 let mut removed = Vec::new();
354 let mut changed = Vec::new();
355
356 let mut files_by_path: HashMap<&str, &ScannedFile> = HashMap::new();
357 for file in files {
358 files_by_path.insert(file.res_path.as_str(), file);
359 }
360
361 let mut old_paths: Vec<&str> = old.by_path.keys().map(String::as_str).collect();
362 old_paths.sort();
363
364 let mut claimed: HashSet<&str> = HashSet::new();
365 let mut accounted: HashSet<&str> = HashSet::new();
366
367 for old_path in &old_paths {
368 if let Some(entry) = old.by_path.get(*old_path) {
369 if let Some(file) = files_by_path.get(*old_path) {
370 accounted.insert(*old_path);
371 claimed.insert(file.res_path.as_str());
372 if file.hash == entry.hash {
373 next.by_path.insert(
374 (*old_path).to_string(),
375 AssetEntry {
376 path: (*old_path).to_string(),
377 uid: entry.uid.clone(),
378 hash: file.hash.clone(),
379 size: file.size,
380 },
381 );
382 } else {
383 changed.push((*old_path).to_string());
384 next.by_path.insert(
385 (*old_path).to_string(),
386 AssetEntry {
387 path: (*old_path).to_string(),
388 uid: entry.uid.clone(),
389 hash: file.hash.clone(),
390 size: file.size,
391 },
392 );
393 }
394 }
395 }
396 }
397
398 let mut orphans: Vec<&AssetEntry> = old_paths
399 .iter()
400 .filter(|p| !accounted.contains(**p))
401 .filter_map(|p| old.by_path.get(*p))
402 .collect();
403 orphans.sort_by(|a, b| a.path.cmp(&b.path));
404
405 let mut unclaimed: Vec<&ScannedFile> = files
406 .iter()
407 .filter(|f| !claimed.contains(f.res_path.as_str()))
408 .collect();
409 unclaimed.sort_by(|a, b| a.res_path.cmp(&b.res_path));
410
411 let mut used: HashSet<usize> = HashSet::new();
412 let mut still_orphaned: Vec<&AssetEntry> = Vec::new();
413 for orphan in orphans {
414 let mut hit: Option<usize> = None;
415 for (idx, file) in unclaimed.iter().enumerate() {
416 if used.contains(&idx) {
417 continue;
418 }
419 if file.hash == orphan.hash && file.size == orphan.size {
420 hit = Some(idx);
421 break;
422 }
423 }
424 match hit {
425 Some(idx) => {
426 used.insert(idx);
427 let file = unclaimed[idx];
428 renamed.push((orphan.path.clone(), file.res_path.clone()));
429 next.by_path.insert(
430 file.res_path.clone(),
431 AssetEntry {
432 path: file.res_path.clone(),
433 uid: orphan.uid.clone(),
434 hash: file.hash.clone(),
435 size: file.size,
436 },
437 );
438 }
439 None => still_orphaned.push(orphan),
440 }
441 }
442
443 for orphan in still_orphaned {
444 removed.push(orphan.path.clone());
445 }
446
447 for (idx, file) in unclaimed.iter().enumerate() {
448 if used.contains(&idx) {
449 continue;
450 }
451 let uid = new_uid(&file.res_path);
452 added.push(file.res_path.clone());
453 next.by_path.insert(
454 file.res_path.clone(),
455 AssetEntry {
456 path: file.res_path.clone(),
457 uid,
458 hash: file.hash.clone(),
459 size: file.size,
460 },
461 );
462 }
463
464 added.sort();
465 renamed.sort();
466 removed.sort();
467 changed.sort();
468
469 (
470 next,
471 ScanReport {
472 scanned: files.len(),
473 added,
474 renamed,
475 removed,
476 changed,
477 },
478 )
479}
480
481pub fn scan_project(root: &Path) -> Result<(UidRegistry, ScanReport)> {
483 let old = load_registry(root)?;
484 let files = scan_files(root)?;
485 let (next, report) = reconcile(&old, &files);
486 save_registry(root, &next)?;
487 Ok((next, report))
488}
489
490pub fn replace_res_ref(text: &str, old: &str, new: &str) -> (String, usize) {
493 let dq_old = format!("\"{old}\"");
494 let dq_new = format!("\"{new}\"");
495 let sq_old = format!("'{old}'");
496 let sq_new = format!("'{new}'");
497 let dq_count = text.matches(dq_old.as_str()).count();
498 let after_dq = text.replace(dq_old.as_str(), dq_new.as_str());
499 let sq_count = after_dq.matches(sq_old.as_str()).count();
500 let out = after_dq.replace(sq_old.as_str(), sq_new.as_str());
501 (out, dq_count + sq_count)
502}
503
504pub fn apply_renames(root: &Path, renames: &[(String, String)]) -> Result<Vec<String>> {
509 if renames.is_empty() {
510 return Ok(Vec::new());
511 }
512 let mut targets: Vec<(String, PathBuf)> = Vec::new();
513 let mut stack = vec![root.to_path_buf()];
514 while let Some(dir) = stack.pop() {
515 let entries =
516 std::fs::read_dir(&dir).with_context(|| format!("read dir {}", dir.display()))?;
517 for entry in entries {
518 let entry = entry.with_context(|| format!("read entry in {}", dir.display()))?;
519 let fs_path = entry.path();
520 let name = entry.file_name().to_string_lossy().into_owned();
521 if name.starts_with('.') {
522 continue;
523 }
524 if fs_path.is_dir() {
525 if is_skipped_dir(&name) {
526 continue;
527 }
528 stack.push(fs_path);
529 continue;
530 }
531 if !fs_path.is_file() {
532 continue;
533 }
534 let ext = fs_path
535 .extension()
536 .and_then(|e| e.to_str())
537 .unwrap_or("")
538 .to_lowercase();
539 if ext != "ptscn" && ext != "py" {
540 continue;
541 }
542 targets.push((res_path_for(root, &fs_path)?, fs_path));
543 }
544 }
545 targets.sort_by(|a, b| a.0.cmp(&b.0));
546
547 let mut touched = Vec::new();
548 for (res_path, fs_path) in &targets {
549 let text = match std::fs::read_to_string(fs_path) {
550 Ok(text) => text,
551 Err(_) => continue,
552 };
553 let mut current = text;
554 let mut total = 0usize;
555 for (old, new) in renames {
556 let (next_text, count) = replace_res_ref(¤t, old, new);
557 current = next_text;
558 total += count;
559 }
560 if total > 0 {
561 std::fs::write(fs_path, current)
562 .with_context(|| format!("write {}", fs_path.display()))?;
563 touched.push(res_path.clone());
564 }
565 }
566 touched.sort();
567 Ok(touched)
568}
569
570pub fn reimport_project(root: &Path) -> Result<(UidRegistry, ReimportReport)> {
574 let (reg, scan) = scan_project(root)?;
575 let importers = default_importers();
576 let mut targets: Vec<&String> = scan.changed.iter().chain(scan.added.iter()).collect();
577 targets.sort();
578
579 let mut reimported = Vec::new();
580 let mut failed = Vec::new();
581 for res_path in targets {
582 let rel = res_path.trim_start_matches("res://");
583 let fs_path = root.join(rel);
584 let ext = fs_path
585 .extension()
586 .and_then(|e| e.to_str())
587 .unwrap_or("")
588 .to_lowercase();
589 let importer = importers.iter().find(|i| i.match_ext(&ext));
590 match importer {
591 Some(importer) => match importer.import(fs_path.to_string_lossy().as_ref()) {
592 Ok(()) => reimported.push(res_path.clone()),
593 Err(_) => failed.push(res_path.clone()),
594 },
595 None => failed.push(res_path.clone()),
596 }
597 }
598 reimported.sort();
599 failed.sort();
600
601 let refs_updated = apply_renames(root, &scan.renamed)?;
602 save_registry(root, ®)?;
603 Ok((
604 reg,
605 ReimportReport {
606 reimported,
607 failed,
608 refs_updated,
609 },
610 ))
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use std::sync::atomic::AtomicU64;
617
618 static TEST_SEQ: AtomicU64 = AtomicU64::new(0);
619
620 fn test_dir(name: &str) -> PathBuf {
621 let seq = TEST_SEQ.fetch_add(1, Ordering::SeqCst);
622 let nanos = SystemTime::now()
623 .duration_since(UNIX_EPOCH)
624 .map(|d| d.as_nanos())
625 .unwrap_or(0);
626 let dir = std::env::temp_dir().join(format!(
627 "pite-assets-{name}-{}-{nanos}-{seq}",
628 std::process::id()
629 ));
630 std::fs::create_dir_all(&dir).expect("create temp test dir");
631 dir
632 }
633
634 fn cleanup(dir: &Path) {
635 let _ = std::fs::remove_dir_all(dir);
636 }
637
638 fn valid_png(extra: &[u8]) -> Vec<u8> {
639 [PNG_SIG.as_slice(), extra].concat()
640 }
641
642 fn valid_wav() -> Vec<u8> {
643 [
644 b"RIFF".as_slice(),
645 &[0x24, 0x00, 0x00, 0x00],
646 b"WAVE".as_slice(),
647 &[0x00, 0x01, 0x02, 0x03],
648 ]
649 .concat()
650 }
651
652 #[test]
653 fn rename_keeps_uid() {
654 let root = test_dir("rename");
655 let old_fs = root.join("a.png");
656 std::fs::write(&old_fs, valid_png(b"hello")).unwrap();
657
658 let (reg1, rep1) = scan_project(&root).unwrap();
659 assert_eq!(rep1.added, vec!["res://a.png".to_string()]);
660 assert!(rep1.renamed.is_empty());
661 let uid1 = reg1.get("res://a.png").unwrap().to_string();
662
663 std::fs::rename(&old_fs, root.join("b.png")).unwrap();
664 let (reg2, rep2) = scan_project(&root).unwrap();
665 assert_eq!(
666 rep2.renamed,
667 vec![("res://a.png".to_string(), "res://b.png".to_string())]
668 );
669 assert!(rep2.added.is_empty());
670 assert!(rep2.removed.is_empty());
671 let uid2 = reg2.get("res://b.png").unwrap().to_string();
672 assert_eq!(uid1, uid2);
673 assert!(reg2.get("res://a.png").is_none());
674
675 cleanup(&root);
676 }
677
678 #[test]
679 fn removed_is_reported() {
680 let root = test_dir("removed");
681 std::fs::write(root.join("gone.png"), valid_png(b"x")).unwrap();
682 let (_, rep1) = scan_project(&root).unwrap();
683 assert_eq!(rep1.added.len(), 1);
684
685 std::fs::remove_file(root.join("gone.png")).unwrap();
686 let (reg2, rep2) = scan_project(&root).unwrap();
687 assert_eq!(rep2.removed, vec!["res://gone.png".to_string()]);
688 assert!(rep2.added.is_empty());
689 assert!(reg2.get("res://gone.png").is_none());
690
691 cleanup(&root);
692 }
693
694 #[test]
695 fn reimport_runs_on_changed_content() {
696 let root = test_dir("reimport");
697 std::fs::write(root.join("sprite.png"), valid_png(b"v1")).unwrap();
698 let (reg1, _) = scan_project(&root).unwrap();
699 let hash1 = reg1
700 .iter()
701 .find(|e| e.path == "res://sprite.png")
702 .unwrap()
703 .hash
704 .clone();
705
706 std::fs::write(root.join("sprite.png"), valid_png(b"v2-longer")).unwrap();
707 let (reg2, report) = reimport_project(&root).unwrap();
708 assert_eq!(report.reimported, vec!["res://sprite.png".to_string()]);
709 assert!(report.failed.is_empty());
710 let hash2 = reg2
711 .iter()
712 .find(|e| e.path == "res://sprite.png")
713 .unwrap()
714 .hash
715 .clone();
716 assert_ne!(hash1, hash2);
717 assert_eq!(hash2, fnv1a_hex(&valid_png(b"v2-longer")));
718
719 cleanup(&root);
720 }
721
722 #[test]
723 fn importers_reject_bad_magic() {
724 let root = test_dir("magic");
725 let bad_png = root.join("bad.png");
726 std::fs::write(&bad_png, b"definitely not a png").unwrap();
727 assert!(PngImporter
728 .import(bad_png.to_string_lossy().as_ref())
729 .is_err());
730 assert!(PngImporter
731 .import(root.join("missing.png").to_string_lossy().as_ref())
732 .is_err());
733 let good_png = root.join("good.png");
734 std::fs::write(&good_png, valid_png(b"payload")).unwrap();
735 assert!(PngImporter
736 .import(good_png.to_string_lossy().as_ref())
737 .is_ok());
738
739 let bad_wav = root.join("bad.wav");
740 std::fs::write(&bad_wav, b"RIFFxxNOTWAVE!").unwrap();
741 assert!(WavImporter
742 .import(bad_wav.to_string_lossy().as_ref())
743 .is_err());
744 let short_wav = root.join("short.wav");
745 std::fs::write(&short_wav, b"RIFF").unwrap();
746 assert!(WavImporter
747 .import(short_wav.to_string_lossy().as_ref())
748 .is_err());
749 let good_wav = root.join("good.wav");
750 std::fs::write(&good_wav, valid_wav()).unwrap();
751 assert!(WavImporter
752 .import(good_wav.to_string_lossy().as_ref())
753 .is_ok());
754
755 assert!(WavImporter.match_ext("wav"));
756 assert!(WavImporter.match_ext("WAV"));
757 assert!(!WavImporter.match_ext("png"));
758
759 let bad_ogg = root.join("bad.ogg");
761 std::fs::write(&bad_ogg, b"NOT_OGG").unwrap();
762 assert!(OggImporter
763 .import(bad_ogg.to_string_lossy().as_ref())
764 .is_err());
765 let good_ogg = root.join("good.ogg");
766 std::fs::write(&good_ogg, b"OggS\x00\x02extra").unwrap();
767 assert!(OggImporter
768 .import(good_ogg.to_string_lossy().as_ref())
769 .is_ok());
770 assert!(OggImporter.match_ext("ogg"));
771 assert!(OggImporter.match_ext("OGG"));
772
773 let bad_mp3 = root.join("bad.mp3");
775 std::fs::write(&bad_mp3, b"NOT_MP3").unwrap();
776 assert!(Mp3Importer
777 .import(bad_mp3.to_string_lossy().as_ref())
778 .is_err());
779 let good_mp3_id3 = root.join("good_id3.mp3");
780 std::fs::write(&good_mp3_id3, b"ID3\x04\x00").unwrap();
781 assert!(Mp3Importer
782 .import(good_mp3_id3.to_string_lossy().as_ref())
783 .is_ok());
784 let good_mp3_sync = root.join("good_sync.mp3");
785 std::fs::write(&good_mp3_sync, &[0xFF, 0xFB, 0x90, 0x64]).unwrap();
786 assert!(Mp3Importer
787 .import(good_mp3_sync.to_string_lossy().as_ref())
788 .is_ok());
789 assert!(Mp3Importer.match_ext("mp3"));
790 assert!(Mp3Importer.match_ext("MP3"));
791
792 let bad_flac = root.join("bad.flac");
794 std::fs::write(&bad_flac, b"NOT_FLAC").unwrap();
795 assert!(FlacImporter
796 .import(bad_flac.to_string_lossy().as_ref())
797 .is_err());
798 let good_flac = root.join("good.flac");
799 std::fs::write(&good_flac, b"fLaC\x00\x00").unwrap();
800 assert!(FlacImporter
801 .import(good_flac.to_string_lossy().as_ref())
802 .is_ok());
803 assert!(FlacImporter.match_ext("flac"));
804 assert!(FlacImporter.match_ext("FLAC"));
805
806 let importers = default_importers();
807 assert_eq!(importers.len(), 5);
808
809 cleanup(&root);
810 }
811
812 #[test]
813 fn replace_res_ref_only_touches_quoted() {
814 let text = "texture = \"res://assets/player.png\"\n\
815 pite.play('res://assets/player.png')\n\
816 bare res://assets/player.png stays\n";
817 let (out, count) =
818 replace_res_ref(text, "res://assets/player.png", "res://assets/hero.png");
819 assert_eq!(count, 2);
820 assert!(out.contains("\"res://assets/hero.png\""));
821 assert!(out.contains("'res://assets/hero.png'"));
822 assert!(out.contains("bare res://assets/player.png stays"));
823 assert!(!out.contains("\"res://assets/player.png\""));
824 assert!(!out.contains("'res://assets/player.png'"));
825
826 let (same, zero) = replace_res_ref("nothing here res://x", "res://x", "res://y");
827 assert_eq!(zero, 0);
828 assert_eq!(same, "nothing here res://x");
829 }
830
831 #[test]
832 fn manifest_round_trip_is_sorted() {
833 let root = test_dir("manifest");
834 std::fs::write(root.join("b.png"), valid_png(b"b")).unwrap();
835 std::fs::write(root.join("a.png"), valid_png(b"a")).unwrap();
836 std::fs::write(root.join("hit.wav"), valid_wav()).unwrap();
837 let (reg1, _) = scan_project(&root).unwrap();
838 assert_eq!(reg1.len(), 3);
839 assert!(!reg1.is_empty());
840
841 let text = std::fs::read_to_string(root.join(MANIFEST_FILE)).unwrap();
842 let pos_a = text.find("res://a.png").expect("a.png in manifest");
843 let pos_b = text.find("res://b.png").expect("b.png in manifest");
844 let pos_w = text.find("res://hit.wav").expect("hit.wav in manifest");
845 assert!(pos_a < pos_b);
846 assert!(pos_b < pos_w);
847
848 let reg2 = load_registry(&root).unwrap();
849 assert_eq!(reg2.len(), 3);
850 for entry in reg1.iter() {
851 let again = reg2
852 .iter()
853 .find(|e| e.path == entry.path)
854 .expect("entry survives round-trip");
855 assert_eq!(again.uid, entry.uid);
856 assert_eq!(again.hash, entry.hash);
857 assert_eq!(again.size, entry.size);
858 }
859
860 let empty_root = test_dir("manifest-empty");
861 let empty = load_registry(&empty_root).unwrap();
862 assert!(empty.is_empty());
863 assert_eq!(empty.len(), 0);
864
865 cleanup(&root);
866 cleanup(&empty_root);
867 }
868
869 #[test]
870 fn scan_skips_hidden_and_target() {
871 let root = test_dir("skips");
872 std::fs::create_dir_all(root.join(".hidden")).unwrap();
873 std::fs::create_dir_all(root.join("target")).unwrap();
874 std::fs::write(root.join(".hidden").join("s.png"), valid_png(b"s")).unwrap();
875 std::fs::write(root.join("target").join("t.png"), valid_png(b"t")).unwrap();
876 std::fs::write(root.join(".dot.png"), valid_png(b"d")).unwrap();
877 std::fs::write(root.join("keep.png"), valid_png(b"k")).unwrap();
878 std::fs::write(root.join("notes.txt"), b"not an asset").unwrap();
879
880 let files = scan_files(&root).unwrap();
881 let paths: Vec<&str> = files.iter().map(|f| f.res_path.as_str()).collect();
882 assert_eq!(paths, vec!["res://keep.png"]);
883
884 cleanup(&root);
885 }
886
887 #[test]
888 fn apply_renames_rewrites_scene_and_script() {
889 let root = test_dir("renames");
890 std::fs::write(root.join("old.png"), valid_png(b"o")).unwrap();
891 let (_, rep1) = scan_project(&root).unwrap();
892 assert_eq!(rep1.added, vec!["res://old.png".to_string()]);
893
894 std::fs::rename(root.join("old.png"), root.join("new.png")).unwrap();
895 std::fs::write(root.join("main.ptscn"), "texture = \"res://old.png\"\n").unwrap();
896 std::fs::write(
897 root.join("play.py"),
898 "pite.play('res://old.png')\nbare res://old.png\n",
899 )
900 .unwrap();
901
902 let (reg, report) = reimport_project(&root).unwrap();
903 assert_eq!(
904 report.refs_updated,
905 vec!["res://main.ptscn".to_string(), "res://play.py".to_string()]
906 );
907 let scene = std::fs::read_to_string(root.join("main.ptscn")).unwrap();
908 assert!(scene.contains("\"res://new.png\""));
909 let script = std::fs::read_to_string(root.join("play.py")).unwrap();
910 assert!(script.contains("'res://new.png'"));
911 assert!(script.contains("bare res://old.png"));
912 assert_eq!(
913 reg.path_for_uid(reg.get("res://new.png").unwrap()),
914 Some("res://new.png")
915 );
916
917 cleanup(&root);
918 }
919}