Skip to main content

pite_assets/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! `pite-assets`: asset scan, uid manifest, rename detection, reimport.
3//!
4//! Single `assets.pite.toml` manifest at the project root maps
5//! `res://` paths to opaque uids. Scans are content-aware: identical
6//! bytes keep the uid across renames, changed bytes flag reimport.
7
8pub 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
21/// Single manifest file at the project root.
22pub const MANIFEST_FILE: &str = "assets.pite.toml";
23
24/// 8-byte PNG signature every valid `.png` must start with.
25const PNG_SIG: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
26
27/// Opaque uid generator state (uniqueness within and across scans).
28static 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/// One manifest row: stable uid plus the content it was last scanned with.
118#[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/// On-disk manifest shape: a diffable `[[asset]]` array sorted by path.
127#[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
192/// FNV-1a 64-bit, rendered as 16 lowercase hex chars. Hashes file bytes
193/// and mints uids alike, with zero extra dependencies.
194fn 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
203/// Mint an opaque 16-lowercase-hex uid bound to `path` plus wall-clock,
204/// pid, and an atomic counter so back-to-back scans never collide.
205fn 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/// One importer-matched file found under the project root.
216#[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/// Pure diff between the last manifest and the current file set.
225/// Every vector is sorted for stable, diffable output.
226#[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/// Outcome of [`reimport_project`]: importer results plus ref rewrites.
236#[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
243/// Directory names never descended into during any walk.
244fn is_skipped_dir(name: &str) -> bool {
245    name == "target" || name == "dist" || name == ".git"
246}
247
248/// `res://`-rooted forward-slash path for `fs_path` under `root`.
249fn 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
261/// Recursive walk of `root`, skipping hidden files/dirs (any component
262/// starting with `.`), `target/`, `dist/`, `.git/`. Keeps only files
263/// whose lowercase extension matches a [`default_importers`] entry.
264pub 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
310/// Load the manifest; a missing file means an empty registry, not an error.
311pub 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
332/// Save the manifest with entries always sorted by path (diffable).
333pub 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
345/// Pure manifest-vs-files diff, no IO. Same path + same hash keeps the
346/// uid; same path + new hash is `changed`; a manifest path missing from
347/// disk but shadowed by an unclaimed file with equal (hash, size) is a
348/// `rename` (uid travels); otherwise `removed` / `added` (fresh uid).
349pub 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
481/// Load + scan + reconcile + save in one step.
482pub 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
490/// Replace only quote-wrapped `res://` refs (`"res://old"`, `'res://old'`)
491/// with the new ref. Bare, unquoted occurrences are left untouched.
492pub 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
504/// Rewrite every `*.ptscn` / `*.py` file under `root` (same skip
505/// rules as [`scan_files`]) for each `(old, new)` rename. Only files
506/// with at least one quote-wrapped hit are written back. Returns the
507/// sorted `res://`-style paths of touched files.
508pub 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(&current, 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
570/// Scan, re-run the matching importer over every `changed`/`added` path
571/// (one failure never aborts the rest), rewrite refs for `renamed`
572/// paths, save, and return the fresh registry plus the report.
573pub 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, &reg)?;
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        // Ogg tests
760        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        // MP3 tests
774        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        // FLAC tests
793        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}