Skip to main content

concinnity_engine/app/
startup_error.rs

1// Classification of a fatal startup failure into the two things it needs to
2// produce: a line for the log, and a sentence for the person looking at the
3// window. `CnResult` is the FFI-facing status enum and carries no context, so
4// the classification happens here where the paths involved are still known.
5
6use crate::result::CnResult;
7use std::path::PathBuf;
8
9/// Why the runtime could not reach a playable state.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum StartupError {
12    /// No compiled world data where the runtime expected it. The usual causes
13    /// are a build that never ran and an installation missing its data folder.
14    MissingData {
15        /// The primary blob file that was looked for.
16        blob: PathBuf,
17    },
18    /// The data is present but did not load: a truncated file, a schema the
19    /// binary no longer understands, or a failed read.
20    UnreadableData {
21        /// The primary blob file that was read.
22        blob: PathBuf,
23        /// What the read reported.
24        cause: CnResult,
25    },
26    /// The world was packaged as one self-contained blob file, but it needs
27    /// overflow payload blobs, which only the directory layout can hold. Their
28    /// siblings would land beside the executable, so this is refused rather
29    /// than half-loaded.
30    OverflowUnsupported {
31        /// The single blob file the world was read from.
32        blob: PathBuf,
33        /// How many further blobs the world spans.
34        needed: u32,
35    },
36    /// Nothing anchored the state tree, so there is nowhere to look for data.
37    NoStateRoot,
38}
39
40impl StartupError {
41    /// Classify a blob-load failure, distinguishing absent data from data that
42    /// is present but unusable, since only the first is the user's to fix.
43    /// `blob` is the primary blob's path, passed in rather than resolved here
44    /// so the classification stays a pure function of its inputs.
45    pub fn from_blob_failure(blob: PathBuf, cause: CnResult) -> Self {
46        if blob.exists() {
47            StartupError::UnreadableData { blob, cause }
48        } else {
49            StartupError::MissingData { blob }
50        }
51    }
52
53    // How the failure surfaces to the process's exit status.
54    pub(crate) fn io_kind(&self) -> std::io::ErrorKind {
55        match self {
56            StartupError::MissingData { .. } | StartupError::NoStateRoot => {
57                std::io::ErrorKind::NotFound
58            }
59            StartupError::UnreadableData { .. } | StartupError::OverflowUnsupported { .. } => {
60                std::io::ErrorKind::InvalidData
61            }
62        }
63    }
64
65    // The sentence shown on the error screen. Names the path, because the
66    // path is the actionable part, and stays free of internal vocabulary.
67    pub(crate) fn user_message(&self) -> String {
68        match self {
69            StartupError::MissingData { blob } => {
70                format!("Failed to find the data blob:\n{}", blob.display())
71            }
72            StartupError::UnreadableData { blob, .. } => {
73                format!("Failed to read the data blob:\n{}", blob.display())
74            }
75            StartupError::OverflowUnsupported { blob, .. } => {
76                format!("This app's data is incomplete:\n{}", blob.display())
77            }
78            StartupError::NoStateRoot => "Failed to find this app's data.".to_string(),
79        }
80    }
81
82    // The developer-facing line, carrying the status the user message omits.
83    pub(crate) fn log_line(&self) -> String {
84        match self {
85            StartupError::MissingData { blob } => {
86                format!(
87                    "no compiled world data at {} -- run `concinnity build` first",
88                    blob.display()
89                )
90            }
91            StartupError::UnreadableData { blob, cause } => {
92                format!(
93                    "compiled world data at {} failed to load: {cause}",
94                    blob.display()
95                )
96            }
97            StartupError::OverflowUnsupported { blob, needed } => {
98                format!(
99                    "{} is a single blob file, but this world spans {} more; \
100                     re-export it so the player ships a `data/` directory",
101                    blob.display(),
102                    needed
103                )
104            }
105            StartupError::NoStateRoot => {
106                "no state directory was installed, so there is nowhere to read world data from"
107                    .to_string()
108            }
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn a_missing_blob_classifies_as_missing() {
119        let dir = tempfile::tempdir().expect("tempdir");
120        let blob = dir.path().join("data").join("0");
121
122        let err = StartupError::from_blob_failure(blob, CnResult::FileIo);
123        assert!(matches!(err, StartupError::MissingData { .. }));
124        assert!(err.user_message().contains("Failed to find"));
125        assert!(err.log_line().contains("concinnity build"));
126    }
127
128    #[test]
129    fn a_present_but_broken_blob_classifies_as_unreadable() {
130        let dir = tempfile::tempdir().expect("tempdir");
131        std::fs::create_dir_all(dir.path().join("data")).expect("data dir");
132        let blob = dir.path().join("data").join("0");
133        std::fs::write(&blob, b"garbage").expect("write blob");
134
135        let err = StartupError::from_blob_failure(blob, CnResult::FileIo);
136        assert!(matches!(err, StartupError::UnreadableData { .. }));
137        assert!(err.user_message().contains("Failed to read"));
138        // The status the user message deliberately omits stays in the log line.
139        assert!(err.log_line().contains(&CnResult::FileIo.to_string()));
140    }
141
142    // Both messages name the path, which is the part the reader can act on.
143    #[test]
144    fn every_message_names_the_blob_path() {
145        for err in [
146            StartupError::MissingData {
147                blob: PathBuf::from("/somewhere/data/0"),
148            },
149            StartupError::UnreadableData {
150                blob: PathBuf::from("/somewhere/data/0"),
151                cause: CnResult::FileIo,
152            },
153            StartupError::OverflowUnsupported {
154                blob: PathBuf::from("/somewhere/data/0"),
155                needed: 2,
156            },
157        ] {
158            assert!(err.user_message().contains("/somewhere/data/0"));
159            assert!(err.log_line().contains("/somewhere/data/0"));
160        }
161    }
162
163    // The refusal has to say what to do about it, since a player cannot tell
164    // from a half-loaded world that its data was packaged in the wrong shape.
165    #[test]
166    fn the_overflow_refusal_names_the_fix() {
167        let err = StartupError::OverflowUnsupported {
168            blob: PathBuf::from("/apps/MyGame/data"),
169            needed: 3,
170        };
171        assert!(err.log_line().contains("single blob file"), "{err:?}");
172        assert!(err.log_line().contains("`data/` directory"), "{err:?}");
173        assert!(err.log_line().contains('3'), "{err:?}");
174        assert_eq!(err.io_kind(), std::io::ErrorKind::InvalidData);
175    }
176
177    // A missing state root is a not-found, not a corrupt-data report: there is
178    // no path to name because nothing anchored one.
179    #[test]
180    fn no_state_root_reports_not_found_without_a_path() {
181        let err = StartupError::NoStateRoot;
182        assert_eq!(err.io_kind(), std::io::ErrorKind::NotFound);
183        assert!(err.log_line().contains("no state directory"));
184    }
185}