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_asset::file).
5
6use alloc::string::String;
7
8use concinnity_asset::cook;
9
10use crate::components::FileKind;
11use crate::ecs::asset_id::AssetId;
12use crate::ecs::{Component, PayloadLocator};
13
14/// References a source file by path.
15///
16/// For supported kinds the build compiles the file into the world (an `.obj`
17/// becomes mesh data); other kinds are path-only references.
18#[derive(Debug, serde::Serialize, serde::Deserialize)]
19pub struct File {
20    /// Assigned by the loader; not authored.
21    pub asset_id: AssetId,
22    /// Path to the source file, relative to the world.
23    pub path: String,
24    /// Content category, derived from the extension when not authored.
25    pub kind: Option<FileKind>,
26    /// Injected at load time for kinds that produce a compiled blob (e.g. obj → mesh payload).
27    pub locator: Option<PayloadLocator>,
28}
29
30impl File {
31    /// Translate the authored args into the runtime file reference: derive
32    /// `kind` from the path extension when unset. Run by cook at build time
33    /// (the baked blob record carries the result).
34    pub fn bake(args: cook::File) -> Self {
35        let kind = args
36            .kind
37            .clone()
38            .or_else(|| super::path_extension(&args.path).and_then(FileKind::from_ext));
39        Self {
40            asset_id: AssetId::default(),
41            path: args.path,
42            kind,
43            locator: None,
44        }
45    }
46}
47
48impl Component for File {
49    const NAME: &'static str = "File";
50
51    fn from_baked(bytes: &[u8]) -> Result<Self, crate::result::CnResult> {
52        Ok(crate::blob::decode_exact(bytes)?)
53    }
54
55    fn inject_locator(&mut self, locator: PayloadLocator) {
56        self.locator = Some(locator);
57    }
58
59    fn inject_name(&mut self, id: AssetId) {
60        self.asset_id = id;
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn from_ext_maps_every_known_extension() {
70        let cases = [
71            ("obj", FileKind::Obj),
72            ("png", FileKind::Png),
73            ("jpg", FileKind::Jpg),
74            ("jpeg", FileKind::Jpeg),
75            ("bmp", FileKind::Bmp),
76            ("tga", FileKind::Tga),
77            ("gif", FileKind::Gif),
78            ("ttf", FileKind::Ttf),
79            ("otf", FileKind::Otf),
80            ("txt", FileKind::Txt),
81            ("md", FileKind::Md),
82            ("mtl", FileKind::Mtl),
83        ];
84        for (ext, want) in cases {
85            assert_eq!(FileKind::from_ext(ext), Some(want.clone()));
86            // Matching is case-insensitive.
87            assert_eq!(FileKind::from_ext(&ext.to_uppercase()), Some(want));
88        }
89        assert_eq!(FileKind::from_ext("zzz"), None);
90    }
91
92    #[test]
93    fn from_args_infers_kind_from_the_extension() {
94        // No explicit kind -> inferred from the path.
95        let f = File::bake(cook::File {
96            path: "models/box.obj".into(),
97            kind: None,
98        });
99        assert_eq!(f.kind, Some(FileKind::Obj));
100        assert_eq!(f.path, "models/box.obj");
101        // An explicit kind is kept even when it disagrees with the extension.
102        let g = File::bake(cook::File {
103            path: "data.obj".into(),
104            kind: Some(FileKind::Txt),
105        });
106        assert_eq!(g.kind, Some(FileKind::Txt));
107        // An unknown extension leaves the kind unset.
108        let h = File::bake(cook::File {
109            path: "notes.zzz".into(),
110            kind: None,
111        });
112        assert_eq!(h.kind, None);
113    }
114
115    #[test]
116    fn is_mesh_is_true_only_for_obj() {
117        assert!(FileKind::Obj.is_mesh());
118        assert!(!FileKind::Png.is_mesh());
119        assert!(!FileKind::Ttf.is_mesh());
120    }
121
122    #[test]
123    fn file_args_and_kind_round_trip_through_json() {
124        let args = cook::File {
125            path: "x.png".into(),
126            kind: Some(FileKind::Png),
127        };
128        let value = serde_json::to_value(&args).unwrap();
129        let back: cook::File = serde_json::from_value(value).unwrap();
130        assert_eq!(back.path, "x.png");
131        assert_eq!(back.kind, Some(FileKind::Png));
132        // FileKind serializes to its lowercase name.
133        assert_eq!(serde_json::to_string(&FileKind::Jpeg).unwrap(), "\"jpeg\"");
134        // to_args mirrors the component fields.
135        assert_eq!(File::bake(args).kind, Some(FileKind::Png));
136    }
137}