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}