Skip to main content

changeset_operations/
error.rs

1use std::path::PathBuf;
2
3use changeset_saga::SagaError;
4use thiserror::Error;
5
6/// Details about a failed compensation during saga rollback.
7#[derive(Debug)]
8pub struct CompensationFailure {
9    /// Name of the step whose compensation failed.
10    pub step: String,
11    /// Description of what the compensation was trying to do.
12    pub description: String,
13    /// The error that occurred during compensation.
14    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
215/// # Errors
216///
217/// Returns [`OperationError::InvalidPrereleaseTag`] when `tag` cannot be parsed.
218pub 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}