Skip to main content

concinnity_core/components/
file.rs

1// src/components/file.rs
2//
3// Runtime `File` component. Its authored args and `FileKind` live in the schema
4// crate (concinnity_core::components::file).
5
6use crate::ecs::asset_id::AssetId;
7use crate::ecs::{Component, PayloadLocator};
8use alloc::string::String;
9
10/// The category of file content, inferred from the extension when not supplied.
11#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum FileKind {
14    /// Wavefront OBJ geometry.
15    Obj,
16    /// PNG image.
17    Png,
18    /// JPEG image, `.jpg`.
19    Jpg,
20    /// JPEG image, `.jpeg`.
21    Jpeg,
22    /// Windows bitmap image.
23    Bmp,
24    /// Truevision TGA image.
25    Tga,
26    /// GIF image.
27    Gif,
28    /// TrueType font.
29    Ttf,
30    /// OpenType font.
31    Otf,
32    /// Plain text.
33    Txt,
34    /// Markdown text, the medium the story importer reads.
35    Md,
36    /// Wavefront material library accompanying an OBJ.
37    Mtl,
38}
39
40impl FileKind {
41    /// The kind an extension names, case-insensitively. `None` for an
42    /// extension the engine does not read.
43    pub fn from_ext(ext: &str) -> Option<Self> {
44        match ext.to_lowercase().as_str() {
45            "obj" => Some(Self::Obj),
46            "png" => Some(Self::Png),
47            "jpg" => Some(Self::Jpg),
48            "jpeg" => Some(Self::Jpeg),
49            "bmp" => Some(Self::Bmp),
50            "tga" => Some(Self::Tga),
51            "gif" => Some(Self::Gif),
52            "ttf" => Some(Self::Ttf),
53            "otf" => Some(Self::Otf),
54            "txt" => Some(Self::Txt),
55            "md" => Some(Self::Md),
56            "mtl" => Some(Self::Mtl),
57            _ => None,
58        }
59    }
60
61    /// Returns true for kinds whose build output is a mesh blob compatible with the
62    /// mesh payload format (vertex + index data readable by GraphicsSystem).
63    pub fn is_mesh(&self) -> bool {
64        matches!(self, Self::Obj)
65    }
66}
67
68/// Authored fields of a `File`.
69#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
70pub struct FileArgs {
71    /// Path to the source file, relative to the project root.
72    pub path: String,
73    /// File category. Inferred from the path extension when absent.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub kind: Option<FileKind>,
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn every_kind_is_reachable_from_an_extension() {
84        // The inference table is the only way a kind is assigned when the args
85        // omit one, so a kind missing from it can never be produced.
86        let all = [
87            ("obj", FileKind::Obj),
88            ("png", FileKind::Png),
89            ("jpg", FileKind::Jpg),
90            ("jpeg", FileKind::Jpeg),
91            ("bmp", FileKind::Bmp),
92            ("tga", FileKind::Tga),
93            ("gif", FileKind::Gif),
94            ("ttf", FileKind::Ttf),
95            ("otf", FileKind::Otf),
96            ("txt", FileKind::Txt),
97            ("md", FileKind::Md),
98            ("mtl", FileKind::Mtl),
99        ];
100        for (ext, kind) in all {
101            assert_eq!(FileKind::from_ext(ext).as_ref(), Some(&kind), "{ext}");
102            // Extensions are matched case-insensitively.
103            assert_eq!(
104                FileKind::from_ext(&ext.to_uppercase()).as_ref(),
105                Some(&kind),
106                "{ext}"
107            );
108        }
109    }
110
111    #[test]
112    fn an_unknown_extension_has_no_kind() {
113        assert_eq!(FileKind::from_ext("wav"), None);
114        assert_eq!(FileKind::from_ext(""), None);
115    }
116
117    #[test]
118    fn only_obj_builds_to_a_mesh_payload() {
119        assert!(FileKind::Obj.is_mesh());
120        for kind in [FileKind::Png, FileKind::Ttf, FileKind::Mtl, FileKind::Md] {
121            assert!(!kind.is_mesh(), "{kind:?}");
122        }
123    }
124
125    #[test]
126    fn args_default_to_an_empty_path_and_inferred_kind() {
127        let args = FileArgs::default();
128        assert!(args.path.is_empty());
129        assert_eq!(args.kind, None);
130    }
131
132    #[test]
133    fn an_absent_kind_is_omitted_from_the_serialized_args() {
134        let args: FileArgs = serde_json::from_str(r#"{"path":"assets/board.obj"}"#).unwrap();
135        assert_eq!(args.path, "assets/board.obj");
136        assert_eq!(args.kind, None);
137        // `cn add` writes normalized args back, so an inferred kind stays absent
138        // rather than being frozen into the world file.
139        assert_eq!(
140            serde_json::to_string(&args).unwrap(),
141            r#"{"path":"assets/board.obj"}"#
142        );
143    }
144
145    #[test]
146    fn an_explicit_kind_round_trips_through_its_lowercase_name() {
147        let args: FileArgs = serde_json::from_str(r#"{"path":"font.dat","kind":"ttf"}"#).unwrap();
148        assert_eq!(args.kind, Some(FileKind::Ttf));
149        assert_eq!(
150            serde_json::to_string(&args).unwrap(),
151            r#"{"path":"font.dat","kind":"ttf"}"#
152        );
153        let bytes = postcard::to_allocvec(&args).unwrap();
154        let back: FileArgs = postcard::from_bytes(&bytes).unwrap();
155        assert_eq!(back.kind, Some(FileKind::Ttf));
156        assert_eq!(back.path, "font.dat");
157    }
158}
159
160/// References a source file by path.
161///
162/// For supported kinds the build compiles the file into the world (an `.obj`
163/// becomes mesh data); other kinds are path-only references.
164#[derive(Debug, serde::Serialize, serde::Deserialize)]
165pub struct File {
166    /// Assigned by the loader; not authored.
167    pub asset_id: AssetId,
168    /// Path to the source file, relative to the world.
169    pub path: String,
170    /// Content category, derived from the extension when not authored.
171    pub kind: Option<FileKind>,
172    /// Injected at load time for kinds that produce a compiled blob (e.g. obj → mesh payload).
173    pub locator: Option<PayloadLocator>,
174}
175
176impl File {
177    /// Translate the authored args into the runtime file reference: derive
178    /// `kind` from the path extension when unset. Run by cook at build time
179    /// (the baked blob record carries the result).
180    pub fn bake(args: FileArgs) -> Self {
181        let kind = args
182            .kind
183            .clone()
184            .or_else(|| super::path_extension(&args.path).and_then(FileKind::from_ext));
185        Self {
186            asset_id: AssetId::default(),
187            path: args.path,
188            kind,
189            locator: None,
190        }
191    }
192}
193
194impl Component for File {
195    const NAME: &'static str = "File";
196
197    fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
198        Ok(crate::blob::decode_exact(bytes)?)
199    }
200
201    fn inject_locator(&mut self, locator: PayloadLocator) {
202        self.locator = Some(locator);
203    }
204
205    fn inject_name(&mut self, id: AssetId) {
206        self.asset_id = id;
207    }
208}
209
210#[cfg(test)]
211mod runtime_tests {
212    use super::*;
213
214    #[test]
215    fn from_ext_maps_every_known_extension() {
216        let cases = [
217            ("obj", FileKind::Obj),
218            ("png", FileKind::Png),
219            ("jpg", FileKind::Jpg),
220            ("jpeg", FileKind::Jpeg),
221            ("bmp", FileKind::Bmp),
222            ("tga", FileKind::Tga),
223            ("gif", FileKind::Gif),
224            ("ttf", FileKind::Ttf),
225            ("otf", FileKind::Otf),
226            ("txt", FileKind::Txt),
227            ("md", FileKind::Md),
228            ("mtl", FileKind::Mtl),
229        ];
230        for (ext, want) in cases {
231            assert_eq!(FileKind::from_ext(ext), Some(want.clone()));
232            // Matching is case-insensitive.
233            assert_eq!(FileKind::from_ext(&ext.to_uppercase()), Some(want));
234        }
235        assert_eq!(FileKind::from_ext("zzz"), None);
236    }
237
238    #[test]
239    fn from_args_infers_kind_from_the_extension() {
240        // No explicit kind -> inferred from the path.
241        let f = File::bake(FileArgs {
242            path: "models/box.obj".into(),
243            kind: None,
244        });
245        assert_eq!(f.kind, Some(FileKind::Obj));
246        assert_eq!(f.path, "models/box.obj");
247        // An explicit kind is kept even when it disagrees with the extension.
248        let g = File::bake(FileArgs {
249            path: "data.obj".into(),
250            kind: Some(FileKind::Txt),
251        });
252        assert_eq!(g.kind, Some(FileKind::Txt));
253        // An unknown extension leaves the kind unset.
254        let h = File::bake(FileArgs {
255            path: "notes.zzz".into(),
256            kind: None,
257        });
258        assert_eq!(h.kind, None);
259    }
260
261    #[test]
262    fn is_mesh_is_true_only_for_obj() {
263        assert!(FileKind::Obj.is_mesh());
264        assert!(!FileKind::Png.is_mesh());
265        assert!(!FileKind::Ttf.is_mesh());
266    }
267
268    #[test]
269    fn file_args_and_kind_round_trip_through_json() {
270        let args = FileArgs {
271            path: "x.png".into(),
272            kind: Some(FileKind::Png),
273        };
274        let value = serde_json::to_value(&args).unwrap();
275        let back: FileArgs = serde_json::from_value(value).unwrap();
276        assert_eq!(back.path, "x.png");
277        assert_eq!(back.kind, Some(FileKind::Png));
278        // FileKind serializes to its lowercase name.
279        assert_eq!(serde_json::to_string(&FileKind::Jpeg).unwrap(), "\"jpeg\"");
280        // to_args mirrors the component fields.
281        assert_eq!(File::bake(args).kind, Some(FileKind::Png));
282    }
283}