1use std::{fmt, process::Termination};
2
3use markdown_compiler::ContentValidationErrors;
4use thiserror::Error;
5
6use crate::config::ConfigurationErrors;
7
8macro_rules! impl_display {
9 ($name:ty { $($pattern:pat => $value:literal),+ $(,)? }) => {
10 impl fmt::Display for $name {
11 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
12 formatter.write_str(match self {
13 $($pattern => $value),+
14 })
15 }
16 }
17 };
18}
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21#[repr(u8)]
22pub enum ProcessExit {
23 Success = 0,
24 Usage = 2,
25 Validation = 65,
26 Internal = 70,
27 Conflict = 75,
28 Configuration = 78,
29}
30
31impl ProcessExit {
32 pub const fn code(self) -> u8 {
33 self as u8
34 }
35}
36
37impl Termination for ProcessExit {
38 fn report(self) -> std::process::ExitCode {
39 std::process::ExitCode::from(self.code())
40 }
41}
42
43#[derive(Debug, Error)]
44pub enum ProcessError {
45 #[error(transparent)]
46 Configuration(#[from] ConfigurationErrors),
47
48 #[error(transparent)]
49 Validation(#[from] ContentValidationErrors),
50
51 #[error("another process already owns a required Maincopy resource")]
52 AlreadyRunning,
53
54 #[error("identity bootstrap is already complete")]
55 IdentityAlreadyBootstrapped,
56
57 #[error("owner credential input is invalid")]
58 IdentityCredentialInvalid,
59
60 #[error("managed Git source mode requires durable source settings")]
61 SourceConfigurationRequired,
62
63 #[error("this source operation requires source.mode = \"managed_git\"")]
64 ManagedSourceDisabled,
65
66 #[error("the selected source credential is not registered by host configuration")]
67 SourceCredentialUnknown,
68
69 #[error("source configuration conflicts with durable state")]
70 SourceConfigurationConflict,
71
72 #[error("source setup requires an existing enabled owner")]
73 SourceOwnerRequired,
74
75 #[error(transparent)]
76 Application(#[from] ApplicationError),
77}
78
79impl ProcessError {
80 pub const fn exit(&self) -> ProcessExit {
81 match self {
82 Self::Configuration(_)
83 | Self::SourceConfigurationRequired
84 | Self::ManagedSourceDisabled => ProcessExit::Configuration,
85 Self::Validation(_)
86 | Self::IdentityCredentialInvalid
87 | Self::SourceCredentialUnknown => ProcessExit::Validation,
88 Self::AlreadyRunning
89 | Self::IdentityAlreadyBootstrapped
90 | Self::SourceConfigurationConflict
91 | Self::SourceOwnerRequired => ProcessExit::Conflict,
92 Self::Application(_) => ProcessExit::Internal,
93 }
94 }
95
96 pub const fn category(&self) -> &'static str {
97 match self {
98 Self::Configuration(_)
99 | Self::SourceConfigurationRequired
100 | Self::ManagedSourceDisabled => "configuration",
101 Self::Validation(_)
102 | Self::IdentityCredentialInvalid
103 | Self::SourceCredentialUnknown => "validation",
104 Self::AlreadyRunning
105 | Self::IdentityAlreadyBootstrapped
106 | Self::SourceConfigurationConflict
107 | Self::SourceOwnerRequired => "conflict",
108 Self::Application(_) => "internal",
109 }
110 }
111}
112
113#[derive(Debug, Error)]
114pub enum ApplicationError {
115 #[error("failed to register the {signal} shutdown signal: {source}")]
116 SignalRegistration {
117 signal: ShutdownSignal,
118 #[source]
119 source: std::io::Error,
120 },
121
122 #[error("the {signal} shutdown signal stream closed")]
123 SignalStreamClosed { signal: ShutdownSignal },
124
125 #[error("this platform does not provide a supported shutdown signal stream")]
126 SignalPlatformUnsupported,
127
128 #[error("critical task {task} exited unexpectedly")]
129 CriticalTaskExited { task: CriticalTaskName },
130
131 #[error("critical task {task} failed: {source}")]
132 CriticalTaskFailed {
133 task: CriticalTaskName,
134 #[source]
135 source: Box<dyn std::error::Error + Send + Sync>,
136 },
137
138 #[error("critical task {task} panicked: {message}")]
139 CriticalTaskPanicked {
140 task: CriticalTaskName,
141 message: Box<str>,
142 },
143
144 #[error("the critical task supervisor failed")]
145 TaskSupervisor {
146 #[source]
147 source: tokio::task::JoinError,
148 },
149
150 #[error("the critical task supervisor became empty unexpectedly")]
151 TaskSupervisorEmpty,
152
153 #[error(
154 "application construction failed during {stage} while attempting to {operation}: {source}"
155 )]
156 Startup {
157 stage: StartupStage,
158 operation: &'static str,
159 #[source]
160 source: Box<dyn std::error::Error + Send + Sync>,
161 },
162}
163
164#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165pub enum ShutdownSignal {
166 Interrupt,
167 Terminate,
168}
169
170impl_display!(ShutdownSignal {
171 Self::Interrupt => "interrupt",
172 Self::Terminate => "terminate",
173});
174
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub enum CriticalTaskName {
177 PublicServer,
178 AdminServer,
179 MetricsServer,
180 MetricsCollector,
181 ContentSync,
182 PublicationCoordinator,
183 DatabaseWriter,
184 Scheduler,
185 SourceSync,
186 MailDispatch,
187 MailFeedback,
188 MailRetention,
189 Worker,
190}
191
192impl_display!(CriticalTaskName {
193 Self::PublicServer => "public server",
194 Self::AdminServer => "admin server",
195 Self::MetricsServer => "metrics server",
196 Self::MetricsCollector => "metrics collector",
197 Self::ContentSync => "content sync",
198 Self::PublicationCoordinator => "publication coordinator",
199 Self::DatabaseWriter => "database writer",
200 Self::Scheduler => "scheduler",
201 Self::SourceSync => "source sync",
202 Self::MailDispatch => "mail dispatch",
203 Self::MailFeedback => "mail feedback",
204 Self::MailRetention => "mail retention",
205 Self::Worker => "worker",
206});
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum StartupStage {
210 Configuration,
211 ProcessLock,
212 Database,
213 Identity,
214 Content,
215 Source,
216 FrontendAssets,
217 Listeners,
218}
219
220impl_display!(StartupStage {
221 Self::Configuration => "configuration",
222 Self::ProcessLock => "process lock",
223 Self::Database => "database startup",
224 Self::Identity => "identity bootstrap",
225 Self::Content => "content compilation",
226 Self::Source => "managed source synchronization",
227 Self::FrontendAssets => "frontend asset validation",
228 Self::Listeners => "listener binding",
229});
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::config::{ConfigurationDiagnostic, ConfigurationValidationCode};
235
236 fn configuration_errors() -> ConfigurationErrors {
237 ConfigurationErrors::from_diagnostics(vec![ConfigurationDiagnostic::new(
238 "$document",
239 ConfigurationValidationCode::HostTomlInvalid,
240 "host TOML does not match the schema",
241 )])
242 }
243
244 fn validation_errors() -> ContentValidationErrors {
245 let root = tempfile::tempdir().unwrap();
246 markdown_compiler::discover_content_tree(
247 &root.path().join("missing"),
248 markdown_compiler::ContentTreeLimits::default(),
249 )
250 .unwrap_err()
251 }
252
253 #[test]
254 fn server_errors_have_stable_exit_codes() {
255 let cases = [
256 (
257 ProcessError::Configuration(configuration_errors()),
258 ProcessExit::Configuration,
259 ),
260 (
261 ProcessError::Validation(validation_errors()),
262 ProcessExit::Validation,
263 ),
264 (ProcessError::AlreadyRunning, ProcessExit::Conflict),
265 (
266 ProcessError::IdentityAlreadyBootstrapped,
267 ProcessExit::Conflict,
268 ),
269 (
270 ProcessError::IdentityCredentialInvalid,
271 ProcessExit::Validation,
272 ),
273 (
274 ProcessError::SourceConfigurationRequired,
275 ProcessExit::Configuration,
276 ),
277 (
278 ProcessError::ManagedSourceDisabled,
279 ProcessExit::Configuration,
280 ),
281 (
282 ProcessError::SourceCredentialUnknown,
283 ProcessExit::Validation,
284 ),
285 (
286 ProcessError::SourceConfigurationConflict,
287 ProcessExit::Conflict,
288 ),
289 (ProcessError::SourceOwnerRequired, ProcessExit::Conflict),
290 (
291 ProcessError::Application(ApplicationError::Startup {
292 stage: StartupStage::Configuration,
293 operation: "load test configuration",
294 source: Box::new(std::io::Error::other("test startup failure")),
295 }),
296 ProcessExit::Internal,
297 ),
298 ];
299
300 for (error, expected_exit) in cases {
301 assert_eq!(error.exit(), expected_exit);
302 }
303 }
304
305 #[test]
306 fn server_exit_values_follow_the_documented_contract() {
307 assert_eq!(ProcessExit::Success.code(), 0);
308 assert_eq!(ProcessExit::Usage.code(), 2);
309 assert_eq!(ProcessExit::Validation.code(), 65);
310 assert_eq!(ProcessExit::Internal.code(), 70);
311 assert_eq!(ProcessExit::Conflict.code(), 75);
312 assert_eq!(ProcessExit::Configuration.code(), 78);
313 }
314
315 #[test]
316 fn server_errors_have_stable_log_categories() {
317 let cases = [
318 (
319 ProcessError::Configuration(configuration_errors()),
320 "configuration",
321 ),
322 (ProcessError::Validation(validation_errors()), "validation"),
323 (ProcessError::AlreadyRunning, "conflict"),
324 (ProcessError::IdentityAlreadyBootstrapped, "conflict"),
325 (ProcessError::IdentityCredentialInvalid, "validation"),
326 (ProcessError::SourceConfigurationRequired, "configuration"),
327 (ProcessError::ManagedSourceDisabled, "configuration"),
328 (ProcessError::SourceCredentialUnknown, "validation"),
329 (ProcessError::SourceConfigurationConflict, "conflict"),
330 (ProcessError::SourceOwnerRequired, "conflict"),
331 (
332 ProcessError::Application(ApplicationError::Startup {
333 stage: StartupStage::Configuration,
334 operation: "load test configuration",
335 source: Box::new(std::io::Error::other("test startup failure")),
336 }),
337 "internal",
338 ),
339 ];
340
341 for (error, category) in cases {
342 assert_eq!(error.category(), category);
343 }
344 }
345
346 #[test]
347 fn error_component_names_have_stable_text() {
348 macro_rules! assert_display {
349 ($($value:expr => $expected:literal),+ $(,)?) => {
350 $(assert_eq!($value.to_string(), $expected);)+
351 };
352 }
353
354 assert_display! {
355 ProcessError::AlreadyRunning =>
356 "another process already owns a required Maincopy resource",
357 ProcessError::IdentityAlreadyBootstrapped =>
358 "identity bootstrap is already complete",
359 ProcessError::IdentityCredentialInvalid =>
360 "owner credential input is invalid",
361 ProcessError::SourceConfigurationRequired =>
362 "managed Git source mode requires durable source settings",
363 ProcessError::ManagedSourceDisabled =>
364 "this source operation requires source.mode = \"managed_git\"",
365 ProcessError::SourceCredentialUnknown =>
366 "the selected source credential is not registered by host configuration",
367 ProcessError::SourceConfigurationConflict =>
368 "source configuration conflicts with durable state",
369 ProcessError::SourceOwnerRequired =>
370 "source setup requires an existing enabled owner",
371 ShutdownSignal::Interrupt => "interrupt",
372 ShutdownSignal::Terminate => "terminate",
373 CriticalTaskName::PublicServer => "public server",
374 CriticalTaskName::AdminServer => "admin server",
375 CriticalTaskName::MetricsServer => "metrics server",
376 CriticalTaskName::MetricsCollector => "metrics collector",
377 CriticalTaskName::ContentSync => "content sync",
378 CriticalTaskName::PublicationCoordinator => "publication coordinator",
379 CriticalTaskName::DatabaseWriter => "database writer",
380 CriticalTaskName::Scheduler => "scheduler",
381 CriticalTaskName::SourceSync => "source sync",
382 CriticalTaskName::MailDispatch => "mail dispatch",
383 CriticalTaskName::MailFeedback => "mail feedback",
384 CriticalTaskName::MailRetention => "mail retention",
385 CriticalTaskName::Worker => "worker",
386 StartupStage::Configuration => "configuration",
387 StartupStage::ProcessLock => "process lock",
388 StartupStage::Database => "database startup",
389 StartupStage::Identity => "identity bootstrap",
390 StartupStage::Content => "content compilation",
391 StartupStage::Source => "managed source synchronization",
392 StartupStage::FrontendAssets => "frontend asset validation",
393 StartupStage::Listeners => "listener binding",
394 }
395 }
396}