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("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
276/// # Errors
277///
278/// Returns [`OperationError::InvalidPrereleaseTag`] when `tag` cannot be parsed.
279pub 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}