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 parse changeset file '{path}'")]
48    ChangesetParse {
49        path: PathBuf,
50        #[source]
51        source: changeset_parse::FormatError,
52    },
53
54    #[error("failed to write changeset file")]
55    ChangesetFileWrite(#[source] std::io::Error),
56
57    #[error("failed to list changeset files in '{path}'")]
58    ChangesetList {
59        path: PathBuf,
60        #[source]
61        source: std::io::Error,
62    },
63
64    #[error("operation cancelled")]
65    Cancelled,
66
67    #[error("no packages found in project at '{0}'")]
68    EmptyProject(PathBuf),
69
70    #[error("unknown package '{name}' (available: {available})")]
71    UnknownPackage { name: String, available: String },
72
73    #[error("missing bump type for package '{package_name}'")]
74    MissingBumpType { package_name: String },
75
76    #[error("missing description")]
77    MissingDescription,
78
79    #[error("description cannot be empty")]
80    EmptyDescription,
81
82    #[error("no packages selected")]
83    NoPackagesSelected,
84
85    #[error("interaction required but provider returned None")]
86    InteractionRequired,
87
88    #[error("IO error")]
89    Io(#[from] std::io::Error),
90
91    #[error("packages with inherited versions require --convert flag: {}", packages.join(", "))]
92    InheritedVersionsRequireConvert { packages: Vec<String> },
93
94    #[error("comparison links enabled but no repository URL available")]
95    ComparisonLinksRequired,
96
97    #[error("working tree has uncommitted changes; commit or stash them, or use --no-commit")]
98    DirtyWorkingTree,
99
100    #[error("current version is stable; please specify a pre-release tag: --prerelease <tag>")]
101    PrereleaseTagRequired,
102
103    #[error("no changesets found; use --force to release without changesets")]
104    NoChangesetsWithoutForce,
105
106    #[error("invalid changeset path '{path}': {reason}")]
107    InvalidChangesetPath { path: PathBuf, reason: &'static str },
108
109    #[error("failed to read release state file '{path}'")]
110    ReleaseStateRead {
111        path: PathBuf,
112        #[source]
113        source: std::io::Error,
114    },
115
116    #[error("failed to write release state file '{path}'")]
117    ReleaseStateWrite {
118        path: PathBuf,
119        #[source]
120        source: std::io::Error,
121    },
122
123    #[error("failed to parse release state file '{path}'")]
124    ReleaseStateParse {
125        path: PathBuf,
126        #[source]
127        source: toml::de::Error,
128    },
129
130    #[error("failed to serialize release state for '{path}'")]
131    ReleaseStateSerialize {
132        path: PathBuf,
133        #[source]
134        source: toml::ser::Error,
135    },
136
137    #[error("release validation failed")]
138    ValidationFailed(#[from] crate::operations::ValidationErrors),
139
140    #[error("failed to parse version '{version}' during {context}")]
141    VersionParse { version: String, context: String },
142
143    #[error("failed to delete {} tag(s) during compensation: {}", failed_tags.len(), failed_tags.join(", "))]
144    TagDeletionFailed { failed_tags: Vec<String> },
145
146    #[error("release saga failed at step '{step}'")]
147    SagaFailed {
148        step: String,
149        #[source]
150        source: Box<OperationError>,
151    },
152
153    #[error(
154        "release saga failed at step '{step}' and {} compensation(s) also failed", compensation_failures.len()
155    )]
156    SagaCompensationFailed {
157        step: String,
158        source: Box<OperationError>,
159        compensation_failures: Vec<CompensationFailure>,
160    },
161}
162
163pub type Result<T> = std::result::Result<T, OperationError>;
164
165impl From<SagaError<OperationError>> for OperationError {
166    fn from(err: SagaError<OperationError>) -> Self {
167        match err {
168            SagaError::StepFailed { step, source } => Self::SagaFailed {
169                step,
170                source: Box::new(source),
171            },
172            SagaError::CompensationFailed {
173                failed_step,
174                step_error,
175                compensation_errors,
176            } => {
177                let compensation_failures = compensation_errors
178                    .into_iter()
179                    .map(|e| CompensationFailure {
180                        step: e.step,
181                        description: e.description,
182                        error: Box::new(e.error),
183                    })
184                    .collect();
185                Self::SagaCompensationFailed {
186                    step: failed_step,
187                    source: Box::new(step_error),
188                    compensation_failures,
189                }
190            }
191            _ => Self::SagaFailed {
192                step: "unknown".to_string(),
193                source: Box::new(Self::Cancelled),
194            },
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn empty_project_error_includes_path() {
205        let err = OperationError::EmptyProject(PathBuf::from("/my/project"));
206
207        let msg = err.to_string();
208
209        assert!(msg.contains("/my/project"));
210    }
211
212    #[test]
213    fn unknown_package_error_includes_name_and_available() {
214        let err = OperationError::UnknownPackage {
215            name: "missing".to_string(),
216            available: "foo, bar".to_string(),
217        };
218
219        let msg = err.to_string();
220
221        assert!(msg.contains("missing"));
222        assert!(msg.contains("foo, bar"));
223    }
224
225    #[test]
226    fn cancelled_error_message() {
227        let err = OperationError::Cancelled;
228
229        assert!(err.to_string().contains("cancelled"));
230    }
231}