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("comparison links enabled but no repository URL available")]
112 ComparisonLinksRequired,
113
114 #[error("comparison links enabled but repository URL could not be parsed")]
115 ComparisonLinksUrlParse(#[source] changeset_changelog::ChangelogError),
116
117 #[error("working tree has uncommitted changes; commit or stash them, or use --no-commit")]
118 DirtyWorkingTree,
119
120 #[error("current version is stable; please specify a pre-release tag: --prerelease <tag>")]
121 PrereleaseTagRequired,
122
123 #[error("no changesets found; use --force to release without changesets")]
124 NoChangesetsWithoutForce,
125
126 #[error("invalid changeset path '{path}': {reason}")]
127 InvalidChangesetPath { path: PathBuf, reason: &'static str },
128
129 #[error("failed to read release state file '{path}'")]
130 ReleaseStateRead {
131 path: PathBuf,
132 #[source]
133 source: std::io::Error,
134 },
135
136 #[error("failed to write release state file '{path}'")]
137 ReleaseStateWrite {
138 path: PathBuf,
139 #[source]
140 source: std::io::Error,
141 },
142
143 #[error("failed to parse release state file '{path}'")]
144 ReleaseStateParse {
145 path: PathBuf,
146 #[source]
147 source: toml::de::Error,
148 },
149
150 #[error("failed to serialize release state for '{path}'")]
151 ReleaseStateSerialize {
152 path: PathBuf,
153 #[source]
154 source: toml::ser::Error,
155 },
156
157 #[error("release validation failed")]
158 ValidationFailed(#[from] crate::operations::ValidationErrors),
159
160 #[error("failed to parse version '{version}' during {context}")]
161 VersionParse {
162 version: String,
163 context: String,
164 #[source]
165 source: semver::Error,
166 },
167
168 #[error("failed to delete {} tag(s) during compensation: {}", failed_tags.len(), failed_tags.join(", "))]
169 TagDeletionFailed { failed_tags: Vec<String> },
170
171 #[error("package '{name}' not found in workspace")]
172 PackageNotFound { name: String },
173
174 #[error("cannot graduate package '{package}' with prerelease version '{version}'")]
175 CannotGraduatePrerelease {
176 package: String,
177 version: semver::Version,
178 },
179
180 #[error("cannot graduate package '{package}' with stable version '{version}' (>= 1.0.0)")]
181 CannotGraduateStable {
182 package: String,
183 version: semver::Version,
184 },
185
186 #[error("invalid pre-release format '{input}' (expected 'crate:tag')")]
187 InvalidPrereleaseFormat { input: String },
188
189 #[error("invalid prerelease tag '{tag}'")]
190 InvalidPrereleaseTag {
191 tag: String,
192 #[source]
193 source: changeset_core::PrereleaseSpecParseError,
194 },
195
196 #[error("release saga failed at step '{step}'")]
197 SagaFailed {
198 step: String,
199 #[source]
200 source: Box<OperationError>,
201 },
202
203 #[error(
204 "release saga failed at step '{step}' and {} compensation(s) also failed", compensation_failures.len()
205 )]
206 SagaCompensationFailed {
207 step: String,
208 source: Box<OperationError>,
209 compensation_failures: Vec<CompensationFailure>,
210 },
211}
212
213pub type Result<T> = std::result::Result<T, OperationError>;
214
215pub fn parse_prerelease_tag(tag: &str) -> Result<changeset_core::PrereleaseSpec> {
219 tag.parse()
220 .map_err(|source| OperationError::InvalidPrereleaseTag {
221 tag: tag.to_string(),
222 source,
223 })
224}
225
226impl From<SagaError<OperationError>> for OperationError {
227 fn from(err: SagaError<OperationError>) -> Self {
228 match err {
229 SagaError::StepFailed { step, source } => Self::SagaFailed {
230 step,
231 source: Box::new(source),
232 },
233 SagaError::CompensationFailed {
234 failed_step,
235 step_error,
236 compensation_errors,
237 } => {
238 let compensation_failures = compensation_errors
239 .into_iter()
240 .map(|e| CompensationFailure {
241 step: e.step,
242 description: e.description,
243 error: Box::new(e.error),
244 })
245 .collect();
246 Self::SagaCompensationFailed {
247 step: failed_step,
248 source: Box::new(step_error),
249 compensation_failures,
250 }
251 }
252 other => Self::SagaFailed {
253 step: other.to_string(),
254 source: Box::new(Self::Cancelled),
255 },
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn empty_project_error_includes_path() {
266 let err = OperationError::EmptyProject(PathBuf::from("/my/project"));
267
268 let msg = err.to_string();
269
270 assert!(msg.contains("/my/project"));
271 }
272
273 #[test]
274 fn unknown_package_error_includes_name_and_available() {
275 let err = OperationError::UnknownPackage {
276 name: "missing".to_string(),
277 available: "foo, bar".to_string(),
278 };
279
280 let msg = err.to_string();
281
282 assert!(msg.contains("missing"));
283 assert!(msg.contains("foo, bar"));
284 }
285
286 #[test]
287 fn cancelled_error_message() {
288 let err = OperationError::Cancelled;
289
290 assert!(err.to_string().contains("cancelled"));
291 }
292
293 #[test]
294 fn project_root_canonicalize_error_includes_path() {
295 let err = OperationError::ProjectRootCanonicalize {
296 path: PathBuf::from("/some/path"),
297 source: std::io::Error::other("test"),
298 };
299 assert!(err.to_string().contains("/some/path"));
300 }
301
302 #[test]
303 fn version_parse_error_includes_version_and_context() {
304 let err = OperationError::VersionParse {
305 version: "not-a-version".to_string(),
306 context: "test context".to_string(),
307 source: "bad".parse::<semver::Version>().expect_err("should fail"),
308 };
309 assert!(err.to_string().contains("not-a-version"));
310 assert!(err.to_string().contains("test context"));
311 }
312
313 #[test]
314 fn parse_prerelease_tag_succeeds_for_valid_tag() {
315 let spec = parse_prerelease_tag("alpha").expect("should parse valid tag");
316
317 assert_eq!(spec.identifier(), "alpha");
318 }
319
320 #[test]
321 fn parse_prerelease_tag_returns_error_for_invalid_tag() {
322 let err = parse_prerelease_tag("bad.tag").expect_err("should fail for invalid tag");
323
324 assert!(matches!(
325 err,
326 OperationError::InvalidPrereleaseTag { ref tag, .. } if tag == "bad.tag"
327 ));
328 }
329}