1use std::path::PathBuf;
2
3use changeset_saga::SagaError;
4use thiserror::Error;
5
6#[derive(Debug)]
8pub struct CompensationFailure {
9 pub step: String,
11 pub description: String,
13 pub error: Box<OperationError>,
15}
16
17#[derive(Debug, Error)]
18pub enum OperationError {
19 #[error(transparent)]
20 Core(#[from] changeset_core::ChangesetError),
21
22 #[error(transparent)]
23 Git(#[from] changeset_git::GitError),
24
25 #[error(transparent)]
26 Project(#[from] changeset_project::ProjectError),
27
28 #[error(transparent)]
29 Parse(#[from] changeset_parse::FormatError),
30
31 #[error(transparent)]
32 Manifest(#[from] changeset_manifest::ManifestError),
33
34 #[error(transparent)]
35 Changelog(#[from] changeset_changelog::ChangelogError),
36
37 #[error("version calculation failed")]
38 VersionCalculation(#[from] changeset_version::VersionError),
39
40 #[error("failed to read changeset file '{path}'")]
41 ChangesetFileRead {
42 path: PathBuf,
43 #[source]
44 source: std::io::Error,
45 },
46
47 #[error("failed to read changelog file '{path}'")]
48 ChangelogFileRead {
49 path: PathBuf,
50 #[source]
51 source: std::io::Error,
52 },
53
54 #[error("failed to parse changeset file '{path}'")]
55 ChangesetParse {
56 path: PathBuf,
57 #[source]
58 source: changeset_parse::FormatError,
59 },
60
61 #[error("failed to write changeset file")]
62 ChangesetFileWrite(#[source] std::io::Error),
63
64 #[error("failed to list changeset files in '{path}'")]
65 ChangesetList {
66 path: PathBuf,
67 #[source]
68 source: std::io::Error,
69 },
70
71 #[error("project root mismatch: provider configured for '{}' but called with '{}'", expected.display(), actual.display())]
72 ProjectRootMismatch { expected: PathBuf, actual: PathBuf },
73
74 #[error("failed to canonicalize project root '{}'", path.display())]
75 ProjectRootCanonicalize {
76 path: PathBuf,
77 #[source]
78 source: std::io::Error,
79 },
80
81 #[error("operation cancelled")]
82 Cancelled,
83
84 #[error("no packages found in project at '{0}'")]
85 EmptyProject(PathBuf),
86
87 #[error("unknown package '{name}' (available: {available})")]
88 UnknownPackage { name: String, available: String },
89
90 #[error("missing bump type for package '{package_name}'")]
91 MissingBumpType { package_name: String },
92
93 #[error("missing description")]
94 MissingDescription,
95
96 #[error("description cannot be empty")]
97 EmptyDescription,
98
99 #[error("no packages selected")]
100 NoPackagesSelected,
101
102 #[error("interaction required but provider returned None")]
103 InteractionRequired,
104
105 #[error("IO error")]
106 Io(#[from] std::io::Error),
107
108 #[error("packages with inherited versions require --convert flag: {}", packages.join(", "))]
109 InheritedVersionsRequireConvert { packages: Vec<String> },
110
111 #[error("changesets with bump type 'none' are disallowed; affected packages: {}", packages.join(", "))]
112 NoneBumpDisallowed { packages: Vec<String> },
113
114 #[error("comparison links enabled but no repository URL available")]
115 ComparisonLinksRequired,
116
117 #[error("comparison links enabled but repository URL could not be parsed")]
118 ComparisonLinksUrlParse(#[source] changeset_changelog::ChangelogError),
119
120 #[error("working tree has uncommitted changes; commit or stash them, or use --no-commit")]
121 DirtyWorkingTree,
122
123 #[error("current version is stable; please specify a pre-release tag: --prerelease <tag>")]
124 PrereleaseTagRequired,
125
126 #[error("no changesets found; use --force to release without changesets")]
127 NoChangesetsWithoutForce,
128
129 #[error("invalid changeset path '{path}': {reason}")]
130 InvalidChangesetPath { path: PathBuf, reason: &'static str },
131
132 #[error("failed to read release state file '{path}'")]
133 ReleaseStateRead {
134 path: PathBuf,
135 #[source]
136 source: std::io::Error,
137 },
138
139 #[error("failed to write release state file '{path}'")]
140 ReleaseStateWrite {
141 path: PathBuf,
142 #[source]
143 source: std::io::Error,
144 },
145
146 #[error("failed to parse release state file '{path}'")]
147 ReleaseStateParse {
148 path: PathBuf,
149 #[source]
150 source: toml::de::Error,
151 },
152
153 #[error("failed to serialize release state for '{path}'")]
154 ReleaseStateSerialize {
155 path: PathBuf,
156 #[source]
157 source: toml::ser::Error,
158 },
159
160 #[error("release validation failed")]
161 ValidationFailed(#[from] crate::operations::ValidationErrors),
162
163 #[error("failed to parse version '{version}' during {context}")]
164 VersionParse {
165 version: String,
166 context: String,
167 #[source]
168 source: semver::Error,
169 },
170
171 #[error("failed to delete {} tag(s) during compensation: {}", failed_tags.len(), failed_tags.join(", "))]
172 TagDeletionFailed { failed_tags: Vec<String> },
173
174 #[error("package '{name}' not found in workspace")]
175 PackageNotFound { name: String },
176
177 #[error("cannot graduate package '{package}' with prerelease version '{version}'")]
178 CannotGraduatePrerelease {
179 package: String,
180 version: semver::Version,
181 },
182
183 #[error("cannot graduate package '{package}' with stable version '{version}' (>= 1.0.0)")]
184 CannotGraduateStable {
185 package: String,
186 version: semver::Version,
187 },
188
189 #[error("invalid pre-release format '{input}' (expected 'crate:tag')")]
190 InvalidPrereleaseFormat { input: String },
191
192 #[error("invalid prerelease tag '{tag}'")]
193 InvalidPrereleaseTag {
194 tag: String,
195 #[source]
196 source: changeset_core::PrereleaseSpecParseError,
197 },
198
199 #[error("failed to read lockfile '{}'", path.display())]
200 LockfileRead {
201 path: PathBuf,
202 #[source]
203 source: std::io::Error,
204 },
205
206 #[error("failed to write lockfile '{}'", path.display())]
207 LockfileWrite {
208 path: PathBuf,
209 #[source]
210 source: std::io::Error,
211 },
212
213 #[error("lockfile generation failed in '{}'", path.display())]
214 LockfileGeneration {
215 path: PathBuf,
216 #[source]
217 source: std::io::Error,
218 },
219
220 #[error("cargo update --workspace failed: {stderr}")]
221 LockfileCommandFailed { stderr: String },
222
223 #[error("release saga failed at step '{step}'")]
224 SagaFailed {
225 step: String,
226 #[source]
227 source: Box<OperationError>,
228 },
229
230 #[error(
231 "release saga failed at step '{step}' and {} compensation(s) also failed", compensation_failures.len()
232 )]
233 SagaCompensationFailed {
234 step: String,
235 source: Box<OperationError>,
236 compensation_failures: Vec<CompensationFailure>,
237 },
238}
239
240pub type Result<T> = std::result::Result<T, OperationError>;
241
242impl From<SagaError<OperationError>> for OperationError {
243 fn from(err: SagaError<OperationError>) -> Self {
244 match err {
245 SagaError::StepFailed { step, source } => Self::SagaFailed {
246 step,
247 source: Box::new(source),
248 },
249 SagaError::CompensationFailed {
250 failed_step,
251 step_error,
252 compensation_errors,
253 } => {
254 let compensation_failures = compensation_errors
255 .into_iter()
256 .map(|e| CompensationFailure {
257 step: e.step,
258 description: e.description,
259 error: Box::new(e.error),
260 })
261 .collect();
262 Self::SagaCompensationFailed {
263 step: failed_step,
264 source: Box::new(step_error),
265 compensation_failures,
266 }
267 }
268 other => Self::SagaFailed {
269 step: other.to_string(),
270 source: Box::new(Self::Cancelled),
271 },
272 }
273 }
274}
275
276pub fn parse_prerelease_tag(tag: &str) -> Result<changeset_core::PrereleaseSpec> {
280 tag.parse()
281 .map_err(|source| OperationError::InvalidPrereleaseTag {
282 tag: tag.to_string(),
283 source,
284 })
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn empty_project_error_includes_path() {
293 let err = OperationError::EmptyProject(PathBuf::from("/my/project"));
294
295 let msg = err.to_string();
296
297 assert!(msg.contains("/my/project"));
298 }
299
300 #[test]
301 fn unknown_package_error_includes_name_and_available() {
302 let err = OperationError::UnknownPackage {
303 name: "missing".to_string(),
304 available: "foo, bar".to_string(),
305 };
306
307 let msg = err.to_string();
308
309 assert!(msg.contains("missing"));
310 assert!(msg.contains("foo, bar"));
311 }
312
313 #[test]
314 fn cancelled_error_message() {
315 let err = OperationError::Cancelled;
316
317 assert!(err.to_string().contains("cancelled"));
318 }
319
320 #[test]
321 fn project_root_canonicalize_error_includes_path() {
322 let err = OperationError::ProjectRootCanonicalize {
323 path: PathBuf::from("/some/path"),
324 source: std::io::Error::other("test"),
325 };
326 assert!(err.to_string().contains("/some/path"));
327 }
328
329 #[test]
330 fn version_parse_error_includes_version_and_context() {
331 let err = OperationError::VersionParse {
332 version: "not-a-version".to_string(),
333 context: "test context".to_string(),
334 source: "bad".parse::<semver::Version>().expect_err("should fail"),
335 };
336 assert!(err.to_string().contains("not-a-version"));
337 assert!(err.to_string().contains("test context"));
338 }
339
340 #[test]
341 fn parse_prerelease_tag_succeeds_for_valid_tag() {
342 let spec = parse_prerelease_tag("alpha").expect("should parse valid tag");
343
344 assert_eq!(spec.identifier(), "alpha");
345 }
346
347 #[test]
348 fn parse_prerelease_tag_returns_error_for_invalid_tag() {
349 let err = parse_prerelease_tag("bad.tag").expect_err("should fail for invalid tag");
350
351 assert!(matches!(
352 err,
353 OperationError::InvalidPrereleaseTag { ref tag, .. } if tag == "bad.tag"
354 ));
355 }
356}