Skip to main content

concinnity_asset/
file.rs

1// File authoring schema. The runtime `File` component lives in core.
2
3use alloc::string::String;
4
5/// The category of file content, inferred from the extension when not supplied.
6#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum FileKind {
9    /// Wavefront OBJ geometry.
10    Obj,
11    /// PNG image.
12    Png,
13    /// JPEG image, `.jpg`.
14    Jpg,
15    /// JPEG image, `.jpeg`.
16    Jpeg,
17    /// Windows bitmap image.
18    Bmp,
19    /// Truevision TGA image.
20    Tga,
21    /// GIF image.
22    Gif,
23    /// TrueType font.
24    Ttf,
25    /// OpenType font.
26    Otf,
27    /// Plain text.
28    Txt,
29    /// Markdown text, the medium the story importer reads.
30    Md,
31    /// Wavefront material library accompanying an OBJ.
32    Mtl,
33}
34
35impl FileKind {
36    /// The kind an extension names, case-insensitively. `None` for an
37    /// extension the engine does not read.
38    pub fn from_ext(ext: &str) -> Option<Self> {
39        match ext.to_lowercase().as_str() {
40            "obj" => Some(Self::Obj),
41            "png" => Some(Self::Png),
42            "jpg" => Some(Self::Jpg),
43            "jpeg" => Some(Self::Jpeg),
44            "bmp" => Some(Self::Bmp),
45            "tga" => Some(Self::Tga),
46            "gif" => Some(Self::Gif),
47            "ttf" => Some(Self::Ttf),
48            "otf" => Some(Self::Otf),
49            "txt" => Some(Self::Txt),
50            "md" => Some(Self::Md),
51            "mtl" => Some(Self::Mtl),
52            _ => None,
53        }
54    }
55
56    /// Returns true for kinds whose build output is a mesh blob compatible with the
57    /// mesh payload format (vertex + index data readable by GraphicsSystem).
58    pub fn is_mesh(&self) -> bool {
59        matches!(self, Self::Obj)
60    }
61}
62
63/// Authored fields of a `File`.
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
65pub struct FileArgs {
66    /// Path to the source file, relative to the project root.
67    pub path: String,
68    /// File category. Inferred from the path extension when absent.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub kind: Option<FileKind>,
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn every_kind_is_reachable_from_an_extension() {
79        // The inference table is the only way a kind is assigned when the args
80        // omit one, so a kind missing from it can never be produced.
81        let all = [
82            ("obj", FileKind::Obj),
83            ("png", FileKind::Png),
84            ("jpg", FileKind::Jpg),
85            ("jpeg", FileKind::Jpeg),
86            ("bmp", FileKind::Bmp),
87            ("tga", FileKind::Tga),
88            ("gif", FileKind::Gif),
89            ("ttf", FileKind::Ttf),
90            ("otf", FileKind::Otf),
91            ("txt", FileKind::Txt),
92            ("md", FileKind::Md),
93            ("mtl", FileKind::Mtl),
94        ];
95        for (ext, kind) in all {
96            assert_eq!(FileKind::from_ext(ext).as_ref(), Some(&kind), "{ext}");
97            // Extensions are matched case-insensitively.
98            assert_eq!(
99                FileKind::from_ext(&ext.to_uppercase()).as_ref(),
100                Some(&kind),
101                "{ext}"
102            );
103        }
104    }
105
106    #[test]
107    fn an_unknown_extension_has_no_kind() {
108        assert_eq!(FileKind::from_ext("wav"), None);
109        assert_eq!(FileKind::from_ext(""), None);
110    }
111
112    #[test]
113    fn only_obj_builds_to_a_mesh_payload() {
114        assert!(FileKind::Obj.is_mesh());
115        for kind in [FileKind::Png, FileKind::Ttf, FileKind::Mtl, FileKind::Md] {
116            assert!(!kind.is_mesh(), "{kind:?}");
117        }
118    }
119
120    #[test]
121    fn args_default_to_an_empty_path_and_inferred_kind() {
122        let args = FileArgs::default();
123        assert!(args.path.is_empty());
124        assert_eq!(args.kind, None);
125    }
126
127    #[test]
128    fn an_absent_kind_is_omitted_from_the_serialized_args() {
129        let args: FileArgs = serde_json::from_str(r#"{"path":"assets/board.obj"}"#).unwrap();
130        assert_eq!(args.path, "assets/board.obj");
131        assert_eq!(args.kind, None);
132        // `cn add` writes normalized args back, so an inferred kind stays absent
133        // rather than being frozen into the world file.
134        assert_eq!(
135            serde_json::to_string(&args).unwrap(),
136            r#"{"path":"assets/board.obj"}"#
137        );
138    }
139
140    #[test]
141    fn an_explicit_kind_round_trips_through_its_lowercase_name() {
142        let args: FileArgs = serde_json::from_str(r#"{"path":"font.dat","kind":"ttf"}"#).unwrap();
143        assert_eq!(args.kind, Some(FileKind::Ttf));
144        assert_eq!(
145            serde_json::to_string(&args).unwrap(),
146            r#"{"path":"font.dat","kind":"ttf"}"#
147        );
148        let bytes = postcard::to_allocvec(&args).unwrap();
149        let back: FileArgs = postcard::from_bytes(&bytes).unwrap();
150        assert_eq!(back.kind, Some(FileKind::Ttf));
151        assert_eq!(back.path, "font.dat");
152    }
153}