concinnity_core/components/
file.rs1use crate::ecs::asset_id::AssetId;
7use crate::ecs::{Component, PayloadLocator};
8use alloc::string::String;
9
10#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum FileKind {
14 Obj,
16 Png,
18 Jpg,
20 Jpeg,
22 Bmp,
24 Tga,
26 Gif,
28 Ttf,
30 Otf,
32 Txt,
34 Md,
36 Mtl,
38}
39
40impl FileKind {
41 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 pub fn is_mesh(&self) -> bool {
64 matches!(self, Self::Obj)
65 }
66}
67
68#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
70pub struct FileArgs {
71 pub path: String,
73 #[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 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 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 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#[derive(Debug, serde::Serialize, serde::Deserialize)]
165pub struct File {
166 pub asset_id: AssetId,
168 pub path: String,
170 pub kind: Option<FileKind>,
172 pub locator: Option<PayloadLocator>,
174}
175
176impl File {
177 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 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 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 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 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 assert_eq!(serde_json::to_string(&FileKind::Jpeg).unwrap(), "\"jpeg\"");
280 assert_eq!(File::bake(args).kind, Some(FileKind::Png));
282 }
283}