Skip to main content

khora_io/asset/
id_registry.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Stable asset-identity registry.
16//!
17//! An [`AssetUUID`] must be **decoupled from the file path** so that renaming or
18//! moving an asset does not change its identity and therefore does not break the
19//! references (`MeshRef::Asset`, `MaterialRef`, texture slots in `.kmat`, …) that
20//! scenes and prefabs store as raw UUID bytes. Deriving the UUID from the path
21//! (`AssetUUID::new_v5(rel)`) breaks on rename; deriving it from the content
22//! breaks on edit. The identity therefore has to be a **persisted token**.
23//!
24//! This module stores those tokens in a single project file,
25//! `<project_root>/.khora/asset-registry.ron`, mapping a forward-slash relative
26//! path (under `assets/`) to its frozen UUID.
27//!
28//! # Lazy freeze
29//!
30//! An asset that has never been renamed has **no entry** and keeps the default
31//! `AssetUUID::new_v5(rel_path)`. An entry is written only the first time an
32//! asset is renamed/moved, freezing its *current* UUID (exactly the value scenes
33//! already reference). Consequences:
34//! - existing projects and tests that assume `UUID == new_v5(path)` keep working
35//!   unchanged as long as no registry entry exists for that path;
36//! - the file stays tiny — it only lists assets whose path has diverged from
37//!   their identity.
38//!
39//! # Format & merge behaviour
40//!
41//! Entries are serialized as RON, **sorted by UUID**, one entry per line. Sorting
42//! by the (immutable) UUID means adding an asset inserts one line, renaming edits
43//! one line's `path` field in place, and deleting removes one line — so two
44//! branches touching *different* assets produce non-overlapping diffs that git
45//! 3-way-merges cleanly. A real conflict arises only when the same asset is
46//! renamed on both branches, which is a genuine conflict.
47//!
48//! # Engine / editor split
49//!
50//! The **read** side (`load`, [`AssetIdRegistry::resolve`]) is engine
51//! infrastructure: the dev VFS ([`crate::asset::IndexBuilder`]), the release
52//! runtime, and the pack builder all resolve UUIDs through it so that
53//! "same UUID in dev and release" holds by construction. The **write** side
54//! ([`AssetIdRegistry::rename`], [`AssetIdRegistry::remove`],
55//! [`AssetIdRegistry::freeze`], [`AssetIdRegistry::save`]) is only ever driven by
56//! the authoring tool (the editor), which owns the project's file operations.
57//!
58//! The registry lives at the **project root**, a sibling of `assets/`, so it is
59//! never seen by the asset scanner, the filesystem watcher, or the packer — all
60//! of which are rooted at `assets/`.
61
62use khora_core::asset::AssetUUID;
63use serde::{Deserialize, Serialize};
64use std::collections::HashMap;
65use std::path::PathBuf;
66
67/// Project-root subdirectory that holds engine-authored project metadata.
68pub const REGISTRY_DIR: &str = ".khora";
69/// File name of the asset-identity registry inside [`REGISTRY_DIR`].
70pub const REGISTRY_FILE: &str = "asset-registry.ron";
71
72/// One persisted `path → uuid` binding.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74struct RegistryEntry {
75    /// Frozen identity of the asset (serialized as a hyphenated UUID string).
76    uuid: AssetUUID,
77    /// Forward-slash relative path under `assets/`.
78    path: String,
79}
80
81/// On-disk shape of `asset-registry.ron`.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83struct RegistryFile {
84    /// Bindings, sorted by UUID for deterministic, merge-friendly output.
85    entries: Vec<RegistryEntry>,
86}
87
88/// Maps forward-slash relative asset paths to their frozen [`AssetUUID`].
89///
90/// See the module documentation for the lazy-freeze semantics and the
91/// engine/editor read/write split.
92#[derive(Debug, Clone)]
93pub struct AssetIdRegistry {
94    /// Project root (the directory that contains `assets/` and `.khora/`).
95    project_root: PathBuf,
96    /// `rel_path → frozen uuid`. Absent paths fall back to `new_v5(rel)`.
97    by_path: HashMap<String, AssetUUID>,
98}
99
100impl AssetIdRegistry {
101    /// Loads the registry for `project_root`.
102    ///
103    /// A missing or unreadable file yields an **empty** registry (every path
104    /// then resolves to its `new_v5` default) — fresh projects and the
105    /// pre-registry world both behave exactly as before. A malformed file is
106    /// logged and treated as empty rather than failing the whole project open.
107    pub fn load(project_root: impl Into<PathBuf>) -> Self {
108        let project_root = project_root.into();
109        let path = project_root.join(REGISTRY_DIR).join(REGISTRY_FILE);
110        let by_path = match std::fs::read_to_string(&path) {
111            Ok(text) => match ron::from_str::<RegistryFile>(&text) {
112                Ok(file) => file.entries.into_iter().map(|e| (e.path, e.uuid)).collect(),
113                Err(e) => {
114                    log::warn!(
115                        "Asset registry at {} is malformed ({e}); treating as empty.",
116                        path.display()
117                    );
118                    HashMap::new()
119                }
120            },
121            Err(_) => HashMap::new(),
122        };
123        Self {
124            project_root,
125            by_path,
126        }
127    }
128
129    /// Resolves the UUID for a forward-slash relative path: the frozen entry if
130    /// one exists, otherwise the default `AssetUUID::new_v5(rel)`.
131    ///
132    /// This is the read primitive used by the index builder, the runtime, and
133    /// the pack builder.
134    pub fn resolve(&self, rel_fwd: &str) -> AssetUUID {
135        self.by_path
136            .get(rel_fwd)
137            .copied()
138            .unwrap_or_else(|| AssetUUID::new_v5(rel_fwd))
139    }
140
141    /// Number of frozen entries (assets whose path has diverged from identity).
142    pub fn len(&self) -> usize {
143        self.by_path.len()
144    }
145
146    /// `true` when no asset has been frozen yet.
147    pub fn is_empty(&self) -> bool {
148        self.by_path.is_empty()
149    }
150
151    /// Explicitly freezes `rel_fwd`'s identity to `uuid`.
152    ///
153    /// Authoring-only. Rarely needed directly — [`Self::rename`] freezes as part
154    /// of the move.
155    pub fn freeze(&mut self, rel_fwd: &str, uuid: AssetUUID) {
156        self.by_path.insert(rel_fwd.to_string(), uuid);
157    }
158
159    /// Moves the identity of `old_rel` to `new_rel`, freezing it in the process.
160    ///
161    /// The new path resolves to **the same UUID** the old path resolved to
162    /// (whether it was already frozen or still on its `new_v5` default), so every
163    /// existing reference keeps pointing at the asset. Authoring-only.
164    pub fn rename(&mut self, old_rel: &str, new_rel: &str) {
165        let uuid = self.resolve(old_rel);
166        self.by_path.remove(old_rel);
167        self.by_path.insert(new_rel.to_string(), uuid);
168    }
169
170    /// Drops any frozen entry for `rel_fwd` (e.g. after the asset is deleted).
171    /// Authoring-only.
172    pub fn remove(&mut self, rel_fwd: &str) {
173        self.by_path.remove(rel_fwd);
174    }
175
176    /// Absolute path of the on-disk registry file
177    /// (`<project_root>/.khora/asset-registry.ron`).
178    pub fn file_path(&self) -> PathBuf {
179        self.project_root.join(REGISTRY_DIR).join(REGISTRY_FILE)
180    }
181
182    /// Writes the registry file if it does not exist yet, so a project has a
183    /// visible `.khora/asset-registry.ron` from first open (confirming that
184    /// persistence is wired). No-op when the file already exists. Authoring-only.
185    pub fn ensure_file(&self) -> std::io::Result<()> {
186        if self.file_path().exists() {
187            return Ok(());
188        }
189        self.save()
190    }
191
192    /// Persists the registry to `<project_root>/.khora/asset-registry.ron`.
193    ///
194    /// Entries are sorted by UUID for deterministic, merge-friendly output, and
195    /// the write is **atomic** (a sibling `*.tmp` file is written then renamed
196    /// over the target) so a crash mid-write cannot corrupt the catalog.
197    /// Authoring-only.
198    pub fn save(&self) -> std::io::Result<()> {
199        let dir = self.project_root.join(REGISTRY_DIR);
200        std::fs::create_dir_all(&dir)?;
201
202        let mut entries: Vec<RegistryEntry> = self
203            .by_path
204            .iter()
205            .map(|(path, uuid)| RegistryEntry {
206                uuid: *uuid,
207                path: path.clone(),
208            })
209            .collect();
210        // Stable order: primary by UUID (immutable → minimal diffs on rename),
211        // secondary by path to fully determine ties.
212        entries.sort_by(|a, b| a.uuid.cmp(&b.uuid).then_with(|| a.path.cmp(&b.path)));
213
214        let file = RegistryFile { entries };
215        let text = ron::ser::to_string_pretty(&file, ron::ser::PrettyConfig::default())
216            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
217
218        let final_path = dir.join(REGISTRY_FILE);
219        let tmp_path = dir.join(format!("{REGISTRY_FILE}.tmp"));
220        std::fs::write(&tmp_path, text.as_bytes())?;
221        std::fs::rename(&tmp_path, &final_path)?;
222        log::debug!(
223            "Asset registry saved ({} frozen entries) → {}",
224            self.by_path.len(),
225            final_path.display()
226        );
227        Ok(())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use tempfile::tempdir;
235
236    #[test]
237    fn resolve_falls_back_to_new_v5_without_entry() {
238        let dir = tempdir().unwrap();
239        let reg = AssetIdRegistry::load(dir.path());
240        assert!(reg.is_empty());
241        assert_eq!(
242            reg.resolve("textures/wood.png"),
243            AssetUUID::new_v5("textures/wood.png"),
244            "an unfrozen path must keep the legacy path-derived UUID"
245        );
246    }
247
248    #[test]
249    fn rename_freezes_and_preserves_uuid() {
250        let dir = tempdir().unwrap();
251        let mut reg = AssetIdRegistry::load(dir.path());
252
253        let original = reg.resolve("meshes/hero.gltf");
254        reg.rename("meshes/hero.gltf", "meshes/protagonist.gltf");
255
256        // The new path resolves to the SAME uuid the old path had — every
257        // scene reference (which stored that uuid) keeps resolving.
258        assert_eq!(reg.resolve("meshes/protagonist.gltf"), original);
259        // The old path reverts to its own default (no longer the frozen id).
260        assert_eq!(
261            reg.resolve("meshes/hero.gltf"),
262            AssetUUID::new_v5("meshes/hero.gltf")
263        );
264    }
265
266    #[test]
267    fn save_then_load_round_trips() {
268        let dir = tempdir().unwrap();
269        let mut reg = AssetIdRegistry::load(dir.path());
270        reg.rename("a/one.png", "a/uno.png");
271        reg.rename("b/two.glb", "b/dos.glb");
272        let frozen_one = reg.resolve("a/uno.png");
273        let frozen_two = reg.resolve("b/dos.glb");
274        reg.save().unwrap();
275
276        let reloaded = AssetIdRegistry::load(dir.path());
277        assert_eq!(reloaded.len(), 2);
278        assert_eq!(reloaded.resolve("a/uno.png"), frozen_one);
279        assert_eq!(reloaded.resolve("b/dos.glb"), frozen_two);
280    }
281
282    #[test]
283    fn remove_drops_entry() {
284        let dir = tempdir().unwrap();
285        let mut reg = AssetIdRegistry::load(dir.path());
286        reg.rename("x/a.png", "x/b.png");
287        assert_eq!(reg.len(), 1);
288        reg.remove("x/b.png");
289        assert!(reg.is_empty());
290    }
291}