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}
91
92impl Display for FleetConfigNameIssue {
93    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94        formatter.write_str(match self {
95            Self::Empty => "must not be empty",
96            Self::InvalidCharacters => "must contain only ASCII letters, numbers, '_' or '-'",
97        })
98    }
99}
100
101///
102/// FleetConfigDeclaration
103///
104/// Required declaration absent from a fleet configuration operation.
105///
106
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub enum FleetConfigDeclaration {
109    FleetName,
110    Role { fleet: String, role: String },
111}
112
113impl Display for FleetConfigDeclaration {
114    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Self::FleetName => formatter.write_str("missing required [fleet].name in canic.toml"),
117            Self::Role { fleet, role } => write!(formatter, "role {fleet}.{role} is not declared"),
118        }
119    }
120}
121
122///
123/// FleetConfigMutationConflict
124///
125/// Existing configuration state that blocks a requested role mutation.
126///
127
128#[derive(Clone, Debug, Eq, PartialEq)]
129pub enum FleetConfigMutationConflict {
130    RoleAlreadyAttached { fleet: String, role: String },
131    RoleAlreadyDeclared { fleet: String, role: String },
132    RootRoleAttach,
133    RootRoleDeclare,
134    RootRoleRename,
135    SameRoleRename,
136}
137
138impl Display for FleetConfigMutationConflict {
139    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::RoleAlreadyAttached { fleet, role } => {
142                write!(formatter, "role {fleet}.{role} is already attached")
143            }
144            Self::RoleAlreadyDeclared { fleet, role } => {
145                write!(formatter, "role {fleet}.{role} is already declared")
146            }
147            Self::RootRoleAttach => {
148                formatter.write_str("root role must already be attached through root topology")
149            }
150            Self::RootRoleDeclare => formatter
151                .write_str("root role must be attached to topology; declare ordinary roles only"),
152            Self::RootRoleRename => {
153                formatter.write_str("root role cannot be renamed through fleet role rename")
154            }
155            Self::SameRoleRename => formatter.write_str("old role and new role must differ"),
156        }
157    }
158}
159
160///
161/// FleetConfigPackageIssue
162///
163/// Generated package metadata invariant violated by a role rename.
164///
165
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub enum FleetConfigPackageIssue {
168    MetadataMissing,
169    MetadataMismatch {
170        expected_fleet: String,
171        expected_role: String,
172    },
173}
174
175impl Display for FleetConfigPackageIssue {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            Self::MetadataMissing => {
179                formatter.write_str("updated manifest would remove [package.metadata.canic]")
180            }
181            Self::MetadataMismatch {
182                expected_fleet,
183                expected_role,
184            } => write!(
185                formatter,
186                "updated manifest would not contain expected Canic metadata fleet={expected_fleet:?} role={expected_role:?}"
187            ),
188        }
189    }
190}
191
192///
193/// FleetConfigTomlOperation
194///
195/// TOML document family whose parser returned the retained source error.
196///
197
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199pub enum FleetConfigTomlOperation {
200    ParseFleetIdentity,
201    ParsePackageManifest,
202}
203
204///
205/// FleetConfigError
206///
207/// Typed failure boundary for fleet configuration projections and mutations.
208///
209
210#[derive(Debug, ThisError)]
211pub enum FleetConfigError {
212    #[error("invalid {}: {source}", path.display())]
213    ConfigInvalid {
214        path: PathBuf,
215        #[source]
216        source: Box<Self>,
217    },
218
219    #[error("{source}")]
220    CoreConfig {
221        operation: FleetConfigOperation,
222        #[source]
223        source: ConfigError,
224    },
225
226    #[error("{declaration}")]
227    DeclarationMissing { declaration: FleetConfigDeclaration },
228
229    #[error("selected config declares fleet {actual:?}, not {expected:?}")]
230    FleetMismatch { actual: String, expected: String },
231
232    #[error("kind must be one of: service, singleton, shard, replica, instance")]
233    InvalidKind { kind: String },
234
235    #[error("{field} {issue}")]
236    InvalidName {
237        field: FleetConfigNameField,
238        issue: FleetConfigNameIssue,
239        value: String,
240    },
241
242    #[error("{detail}")]
243    InvalidTableHeader { detail: &'static str },
244
245    #[error("failed to {operation} {}: {source}", path.display())]
246    Io {
247        operation: FleetConfigIoOperation,
248        path: PathBuf,
249        #[source]
250        source: io::Error,
251    },
252
253    #[error("{conflict}")]
254    MutationConflict {
255        conflict: FleetConfigMutationConflict,
256    },
257
258    #[error("updated {}: {issue}", path.display())]
259    PackageMetadataInvalid {
260        path: PathBuf,
261        issue: FleetConfigPackageIssue,
262    },
263
264    #[error("{}", format_role_contract_findings(.errors))]
265    RoleContractRejected { errors: Vec<RoleContractFinding> },
266
267    #[error("{mutation}; {rollback}")]
268    RollbackFailed {
269        mutation: Box<Self>,
270        rollback: Box<Self>,
271    },
272
273    #[error("{source}")]
274    Toml {
275        operation: FleetConfigTomlOperation,
276        #[source]
277        source: toml::de::Error,
278    },
279}
280
281impl FleetConfigError {
282    pub(super) fn at_config_path(self, path: &Path) -> Self {
283        match self {
284            Self::ConfigInvalid { .. } | Self::Io { .. } => self,
285            source => Self::ConfigInvalid {
286                path: path.to_path_buf(),
287                source: Box::new(source),
288            },
289        }
290    }
291
292    pub(super) fn io(operation: FleetConfigIoOperation, path: &Path, source: io::Error) -> Self {
293        Self::Io {
294            operation,
295            path: path.to_path_buf(),
296            source,
297        }
298    }
299}
300
301fn format_role_contract_findings(errors: &[RoleContractFinding]) -> String {
302    errors
303        .iter()
304        .map(|finding| {
305            format!(
306                "{}: {}",
307                finding.code(),
308                crate::role_contract::finding_detail(finding)
309            )
310        })
311        .collect::<Vec<_>>()
312        .join("; ")
313}