Skip to main content

canic_host/release_set/config/
error.rs

1//! Module: release_set::config::error
2//!
3//! Responsibility: classify fleet-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/// FleetConfigOperation
18///
19/// Bounded configuration operation attached to core parsing failures.
20///
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum FleetConfigOperation {
24    AttachRole,
25    DeclareRole,
26    Project,
27    RenameRole,
28}
29
30///
31/// FleetConfigIoOperation
32///
33/// Filesystem operation retained with its path and original source.
34///
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum FleetConfigIoOperation {
38    ReadConfig,
39    ReadPackageManifest,
40    RestoreConfig,
41    WriteConfig,
42    WritePackageManifest,
43}
44
45impl Display for FleetConfigIoOperation {
46    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47        formatter.write_str(match self {
48            Self::ReadConfig => "read fleet config",
49            Self::ReadPackageManifest => "read package manifest",
50            Self::RestoreConfig => "restore fleet config",
51            Self::WriteConfig => "write fleet config",
52            Self::WritePackageManifest => "write package manifest",
53        })
54    }
55}
56
57///
58/// FleetConfigNameField
59///
60/// Input-name family used by typed fleet mutation validation.
61///
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum FleetConfigNameField {
65    Package,
66    Role,
67    Subnet,
68}
69
70impl Display for FleetConfigNameField {
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/// FleetConfigNameIssue
82///
83/// Reason a bounded fleet mutation name is invalid.
84///
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum FleetConfigNameIssue {
88    Empty,
89    InvalidCharacters,
90    InvalidSnakeCase,
91    TooLong { max_bytes: usize },
92}
93
94impl Display for FleetConfigNameIssue {
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/// FleetConfigDeclaration
113///
114/// Required declaration absent from a fleet configuration operation.
115///
116
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub enum FleetConfigDeclaration {
119    FleetName,
120    Role { fleet: String, role: String },
121}
122
123impl Display for FleetConfigDeclaration {
124    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            Self::FleetName => formatter.write_str("missing required [fleet].name in canic.toml"),
127            Self::Role { fleet, role } => write!(formatter, "role {fleet}.{role} is not declared"),
128        }
129    }
130}
131
132///
133/// FleetConfigMutationConflict
134///
135/// Existing configuration state that blocks a requested role mutation.
136///
137
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub enum FleetConfigMutationConflict {
140    RoleAlreadyAttached { fleet: String, role: String },
141    RoleAlreadyDeclared { fleet: String, role: String },
142    RootRoleAttach,
143    RootRoleDeclare,
144    RootRoleRename,
145    SameRoleRename,
146}
147
148impl Display for FleetConfigMutationConflict {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Self::RoleAlreadyAttached { fleet, role } => {
152                write!(formatter, "role {fleet}.{role} is already attached")
153            }
154            Self::RoleAlreadyDeclared { fleet, role } => {
155                write!(formatter, "role {fleet}.{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 fleet role rename")
164            }
165            Self::SameRoleRename => formatter.write_str("old role and new role must differ"),
166        }
167    }
168}
169
170///
171/// FleetConfigPackageIssue
172///
173/// Generated package metadata invariant violated by a role rename.
174///
175
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub enum FleetConfigPackageIssue {
178    MetadataMissing,
179    MetadataMismatch {
180        expected_fleet: String,
181        expected_role: String,
182    },
183}
184
185impl Display for FleetConfigPackageIssue {
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_fleet,
193                expected_role,
194            } => write!(
195                formatter,
196                "updated manifest would not contain expected Canic metadata fleet={expected_fleet:?} role={expected_role:?}"
197            ),
198        }
199    }
200}
201
202///
203/// FleetConfigTomlOperation
204///
205/// TOML document family whose parser returned the retained source error.
206///
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum FleetConfigTomlOperation {
210    ParseFleetIdentity,
211    ParsePackageManifest,
212}
213
214///
215/// FleetConfigError
216///
217/// Typed failure boundary for fleet configuration projections and mutations.
218///
219
220#[derive(Debug, ThisError)]
221pub enum FleetConfigError {
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: FleetConfigOperation,
232        #[source]
233        source: ConfigError,
234    },
235
236    #[error("{declaration}")]
237    DeclarationMissing { declaration: FleetConfigDeclaration },
238
239    #[error("selected config declares fleet {actual:?}, not {expected:?}")]
240    FleetMismatch { 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: FleetConfigNameField,
248        issue: FleetConfigNameIssue,
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: FleetConfigIoOperation,
258        path: PathBuf,
259        #[source]
260        source: io::Error,
261    },
262
263    #[error("{conflict}")]
264    MutationConflict {
265        conflict: FleetConfigMutationConflict,
266    },
267
268    #[error("updated {}: {issue}", path.display())]
269    PackageMetadataInvalid {
270        path: PathBuf,
271        issue: FleetConfigPackageIssue,
272    },
273
274    #[error("{}", format_role_contract_findings(.errors))]
275    RoleContractRejected { errors: Vec<RoleContractFinding> },
276
277    #[error("{mutation}; {rollback}")]
278    RollbackFailed {
279        mutation: Box<Self>,
280        rollback: Box<Self>,
281    },
282
283    #[error("{source}")]
284    Toml {
285        operation: FleetConfigTomlOperation,
286        #[source]
287        source: toml::de::Error,
288    },
289}
290
291impl FleetConfigError {
292    pub(super) fn at_config_path(self, path: &Path) -> Self {
293        match self {
294            Self::ConfigInvalid { .. } | Self::Io { .. } => self,
295            source => Self::ConfigInvalid {
296                path: path.to_path_buf(),
297                source: Box::new(source),
298            },
299        }
300    }
301
302    pub(super) fn io(operation: FleetConfigIoOperation, path: &Path, source: io::Error) -> Self {
303        Self::Io {
304            operation,
305            path: path.to_path_buf(),
306            source,
307        }
308    }
309}
310
311fn format_role_contract_findings(errors: &[RoleContractFinding]) -> String {
312    errors
313        .iter()
314        .map(|finding| {
315            format!(
316                "{}: {}",
317                finding.code(),
318                crate::role_contract::finding_detail(finding)
319            )
320        })
321        .collect::<Vec<_>>()
322        .join("; ")
323}