Skip to main content

canic_host/release_set/config/
error.rs

1//! Module: release_set::config::error
2//!
3//! Responsibility: classify App-configuration projection and mutation failures.
4//! Does not own: configuration policy, TOML mutation, rollback execution, or CLI exits.
5//! Boundary: retains typed core, TOML, I/O, mutation, and rollback causes for callers.
6
7use std::{
8    fmt::{self, Display},
9    io,
10    path::{Path, PathBuf},
11};
12
13use canic_core::{bootstrap::ConfigError, role_contract::RoleContractFinding};
14use thiserror::Error as ThisError;
15
16///
17/// AppConfigOperation
18///
19/// Bounded configuration operation attached to core parsing failures.
20///
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum AppConfigOperation {
24    AttachRole,
25    DeclareRole,
26    Project,
27    RenameRole,
28}
29
30///
31/// AppConfigIoOperation
32///
33/// Filesystem operation retained with its path and original source.
34///
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum AppConfigIoOperation {
38    ReadConfig,
39    ReadPackageManifest,
40    RestoreConfig,
41    WriteConfig,
42    WritePackageManifest,
43}
44
45impl Display for AppConfigIoOperation {
46    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47        formatter.write_str(match self {
48            Self::ReadConfig => "read App config",
49            Self::ReadPackageManifest => "read package manifest",
50            Self::RestoreConfig => "restore App config",
51            Self::WriteConfig => "write App config",
52            Self::WritePackageManifest => "write package manifest",
53        })
54    }
55}
56
57///
58/// AppConfigNameField
59///
60/// Input-name family used by typed app mutation validation.
61///
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum AppConfigNameField {
65    Package,
66    Role,
67    Subnet,
68}
69
70impl Display for AppConfigNameField {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        formatter.write_str(match self {
73            Self::Package => "package",
74            Self::Role => "role",
75            Self::Subnet => "subnet",
76        })
77    }
78}
79
80///
81/// AppConfigNameIssue
82///
83/// Reason a bounded app mutation name is invalid.
84///
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum AppConfigNameIssue {
88    Empty,
89    InvalidCharacters,
90    InvalidSnakeCase,
91    TooLong { max_bytes: usize },
92}
93
94impl Display for AppConfigNameIssue {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::Empty => formatter.write_str("must not be empty"),
98            Self::InvalidCharacters => formatter.write_str(
99                "must begin with an ASCII letter, number, or '_' and contain only ASCII letters, numbers, '_' or '-'",
100            ),
101            Self::InvalidSnakeCase => formatter.write_str(
102                "must use lowercase snake_case beginning with an ASCII letter, with nonempty lowercase alphanumeric words separated by single '_' characters",
103            ),
104            Self::TooLong { max_bytes } => {
105                write!(formatter, "must not exceed {max_bytes} bytes")
106            }
107        }
108    }
109}
110
111///
112/// AppConfigDeclaration
113///
114/// Required declaration absent from an App configuration operation.
115///
116
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub enum AppConfigDeclaration {
119    AppName,
120    Role { app: String, role: String },
121}
122
123impl Display for AppConfigDeclaration {
124    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            Self::AppName => formatter.write_str("missing required [app].name in canic.toml"),
127            Self::Role { app, role } => write!(formatter, "role {app}.{role} is not declared"),
128        }
129    }
130}
131
132///
133/// AppConfigMutationConflict
134///
135/// Existing configuration state that blocks a requested role mutation.
136///
137
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub enum AppConfigMutationConflict {
140    RoleAlreadyAttached { app: String, role: String },
141    RoleAlreadyDeclared { app: String, role: String },
142    RootRoleAttach,
143    RootRoleDeclare,
144    RootRoleRename,
145    SameRoleRename,
146}
147
148impl Display for AppConfigMutationConflict {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Self::RoleAlreadyAttached { app, role } => {
152                write!(formatter, "role {app}.{role} is already attached")
153            }
154            Self::RoleAlreadyDeclared { app, role } => {
155                write!(formatter, "role {app}.{role} is already declared")
156            }
157            Self::RootRoleAttach => {
158                formatter.write_str("root role must already be attached through root topology")
159            }
160            Self::RootRoleDeclare => formatter
161                .write_str("root role must be attached to topology; declare ordinary roles only"),
162            Self::RootRoleRename => {
163                formatter.write_str("root role cannot be renamed through app role rename")
164            }
165            Self::SameRoleRename => formatter.write_str("old role and new role must differ"),
166        }
167    }
168}
169
170///
171/// AppConfigPackageIssue
172///
173/// Generated package metadata invariant violated by a role rename.
174///
175
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub enum AppConfigPackageIssue {
178    MetadataMissing,
179    MetadataMismatch {
180        expected_app: String,
181        expected_role: String,
182    },
183}
184
185impl Display for AppConfigPackageIssue {
186    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
187        match self {
188            Self::MetadataMissing => {
189                formatter.write_str("updated manifest would remove [package.metadata.canic]")
190            }
191            Self::MetadataMismatch {
192                expected_app,
193                expected_role,
194            } => write!(
195                formatter,
196                "updated manifest would not contain expected Canic App identity {expected_app:?} and role {expected_role:?}"
197            ),
198        }
199    }
200}
201
202///
203/// AppConfigTomlOperation
204///
205/// TOML document family whose parser returned the retained source error.
206///
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum AppConfigTomlOperation {
210    ParseAppIdentity,
211    ParsePackageManifest,
212}
213
214///
215/// AppConfigError
216///
217/// Typed failure boundary for App configuration projections and mutations.
218///
219
220#[derive(Debug, ThisError)]
221pub enum AppConfigError {
222    #[error("invalid {}: {source}", path.display())]
223    ConfigInvalid {
224        path: PathBuf,
225        #[source]
226        source: Box<Self>,
227    },
228
229    #[error("{source}")]
230    CoreConfig {
231        operation: AppConfigOperation,
232        #[source]
233        source: ConfigError,
234    },
235
236    #[error("{declaration}")]
237    DeclarationMissing { declaration: AppConfigDeclaration },
238
239    #[error("selected config declares App {actual:?}, not {expected:?}")]
240    AppMismatch { actual: String, expected: String },
241
242    #[error("kind must be one of: service, singleton, shard, replica, instance")]
243    InvalidKind { kind: String },
244
245    #[error("{field} {issue}")]
246    InvalidName {
247        field: AppConfigNameField,
248        issue: AppConfigNameIssue,
249        value: String,
250    },
251
252    #[error("{detail}")]
253    InvalidTableHeader { detail: &'static str },
254
255    #[error("failed to {operation} {}: {source}", path.display())]
256    Io {
257        operation: AppConfigIoOperation,
258        path: PathBuf,
259        #[source]
260        source: io::Error,
261    },
262
263    #[error("{conflict}")]
264    MutationConflict { conflict: AppConfigMutationConflict },
265
266    #[error("updated {}: {issue}", path.display())]
267    PackageMetadataInvalid {
268        path: PathBuf,
269        issue: AppConfigPackageIssue,
270    },
271
272    #[error("{}", format_role_contract_findings(.errors))]
273    RoleContractRejected { errors: Vec<RoleContractFinding> },
274
275    #[error("{mutation}; {rollback}")]
276    RollbackFailed {
277        mutation: Box<Self>,
278        rollback: Box<Self>,
279    },
280
281    #[error("{source}")]
282    Toml {
283        operation: AppConfigTomlOperation,
284        #[source]
285        source: toml::de::Error,
286    },
287}
288
289impl AppConfigError {
290    pub(super) fn at_config_path(self, path: &Path) -> Self {
291        match self {
292            Self::ConfigInvalid { .. } | Self::Io { .. } => self,
293            source => Self::ConfigInvalid {
294                path: path.to_path_buf(),
295                source: Box::new(source),
296            },
297        }
298    }
299
300    pub(super) fn io(operation: AppConfigIoOperation, path: &Path, source: io::Error) -> Self {
301        Self::Io {
302            operation,
303            path: path.to_path_buf(),
304            source,
305        }
306    }
307}
308
309fn format_role_contract_findings(errors: &[RoleContractFinding]) -> String {
310    errors
311        .iter()
312        .map(|finding| {
313            format!(
314                "{}: {}",
315                finding.code(),
316                crate::role_contract::finding_detail(finding)
317            )
318        })
319        .collect::<Vec<_>>()
320        .join("; ")
321}