Skip to main content

callisto_graph/
error.rs

1use std::path::PathBuf;
2
3use callisto_model::{Ecosystem, GroupName, ManifestError, PackageId, TagTemplateError, VersionParseError};
4
5pub use crate::locate::LocateError;
6
7#[derive(Clone, Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
8#[allow(clippy::result_large_err)]
9#[non_exhaustive]
10pub enum GraphError {
11    #[error(transparent)]
12    #[diagnostic(transparent)]
13    Locate(#[from] LocateError),
14
15    #[error(transparent)]
16    #[diagnostic(transparent)]
17    Manifest(#[from] ManifestError),
18
19    #[error(transparent)]
20    #[diagnostic(transparent)]
21    Config(#[from] ConfigError),
22
23    #[error(transparent)]
24    #[diagnostic(transparent)]
25    Format(#[from] callisto_format::ParseError),
26
27    #[error("parsing changeset {}: {source}", .path.display())]
28    ParseChangeset {
29        path: PathBuf,
30        source: callisto_format::ParseError,
31    },
32
33    #[error(transparent)]
34    #[diagnostic(transparent)]
35    Bump(#[from] callisto_format::BumpError),
36
37    #[error(transparent)]
38    #[diagnostic(transparent)]
39    Changelog(#[from] callisto_changelog::ChangelogError),
40
41    #[cfg(feature = "inference")]
42    #[error(transparent)]
43    Conventional(#[from] callisto_conventional::ConventionalError),
44
45    #[error(transparent)]
46    TagTemplate(#[from] callisto_model::TagTemplateError),
47
48    #[error(transparent)]
49    VersionParse(#[from] callisto_model::VersionParseError),
50
51    #[error(transparent)]
52    #[diagnostic(transparent)]
53    Model(#[from] callisto_model::ModelError),
54
55    #[error(transparent)]
56    #[diagnostic(transparent)]
57    Vcs(#[from] callisto_vcs::VcsError),
58
59    #[error("command error: {0}")]
60    Command(#[from] callisto_model::CommandError),
61
62    #[error("package `{id}` is defined at multiple paths: {}", .paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", "))]
63    #[diagnostic(code(E100), help("Ensure package IDs are unique across workspace manifest paths."))]
64    DuplicatePackage { id: PackageId, paths: Vec<PathBuf> },
65
66    #[error("package at `{path}` declares conflicting identities: {}", .ids.iter().map(|i| i.display_name()).collect::<Vec<_>>().join(", "))]
67    #[diagnostic(code(E101), help("Align package name declarations in manifest files."))]
68    SplitIdentity { path: PathBuf, ids: Vec<PackageId> },
69
70    #[error("package `{id}` was not found in the workspace")]
71    #[diagnostic(
72        code(E102),
73        help("Verify package is included in workspace members in callisto.toml.")
74    )]
75    UnknownPackage { id: PackageId },
76
77    #[error("name `{name}` is ambiguous in this workspace; candidates: {}", .candidates.iter().map(|c| c.display_name()).collect::<Vec<_>>().join(", "))]
78    #[diagnostic(
79        code(E103),
80        help("Use fully-qualified package ID with ecosystem prefix (e.g. cargo:pkg).")
81    )]
82    AmbiguousName { name: String, candidates: Vec<PackageId> },
83
84    #[error("dependency cycle detected: {}", .cycle.iter().map(|i| i.display_name()).collect::<Vec<_>>().join(" -> "))]
85    #[diagnostic(
86        code(E104),
87        help("Refactor workspace dependencies to break the cyclic dependency chain.")
88    )]
89    Cycle { cycle: Vec<PackageId> },
90
91    #[error("cascade failed to converge after {iterations} iterations")]
92    #[diagnostic(code(E105), help("Check for oscillating peer or linked group dependencies."))]
93    CascadeNotConverged { iterations: usize },
94
95    #[error("fixed group `{group}` members have divergent on-disk versions: {}", .members.iter().map(|(id, v)| format!("{}={}", id.display_name(), v.render())).collect::<Vec<_>>().join(", "))]
96    #[diagnostic(code(E106), help("Align on-disk versions for all members of the fixed group."))]
97    FixedGroupDivergent {
98        group: GroupName,
99        members: Vec<(PackageId, callisto_model::Version)>,
100    },
101
102    #[error("group `{group}` members use incompatible versioning grammars: {}", .members.iter().map(|(id, v)| format!("{}={:?}", id.display_name(), v.grammar())).collect::<Vec<_>>().join(", "))]
103    #[diagnostic(code(E107))]
104    GroupGrammarMismatch {
105        group: GroupName,
106        members: Vec<(PackageId, callisto_model::Version)>,
107    },
108
109    #[error("group `{group}` lists member `{member}`, which was not found in the workspace")]
110    #[diagnostic(code(E108))]
111    MissingGroupMember { group: GroupName, member: String },
112
113    #[error("package `{package}` is listed in multiple conflicting groups: {}", .groups.iter().map(|g| g.as_str()).collect::<Vec<_>>().join(", "))]
114    #[diagnostic(code(E109))]
115    ConflictingGroupMembership { package: PackageId, groups: Vec<GroupName> },
116
117    #[error("version dependency edge from `{from}` to `{to}` involves incompatible grammars: {source}")]
118    GrammarMismatch {
119        from: PackageId,
120        to: PackageId,
121        #[source]
122        source: callisto_model::GrammarMismatch,
123    },
124
125    #[error("on-disk versions changed since plan was generated for `{package}`: expected {}, found {}", .expected.render(), .found.render())]
126    OnDiskVersionDrift {
127        package: PackageId,
128        expected: callisto_model::Version,
129        found: callisto_model::Version,
130    },
131
132    #[error("cannot apply version plan: manifest `{}` is at version {}, expected {} (pre-apply) or {} (already applied — safe to retry)", .path.display(), .found.render(), .expected_from.render(), .expected_to.render())]
133    #[diagnostic(
134        code(E117),
135        help(
136            "The manifest version does not match the plan's from or to version. \
137              This may indicate the manifest was modified outside of callisto after the plan was generated."
138        )
139    )]
140    UnexpectedManifestVersion {
141        path: PathBuf,
142        expected_from: callisto_model::Version,
143        expected_to: callisto_model::Version,
144        found: callisto_model::Version,
145    },
146
147    #[error("workspace root `{root_manifest}` has conflicting version updates: {details}")]
148    WorkspaceVersionConflict { root_manifest: PathBuf, details: String },
149
150    #[error("failed to parse .changeset/pre.json: {0}")]
151    #[diagnostic(
152        code(E114),
153        help("Check that .changeset/pre.json is valid JSON and was not partially written. Delete the file and re-run `callisto pre enter` to recover.")
154    )]
155    PreJson(callisto_format::PreJsonError),
156
157    #[error("failed to read .changeset/pre.json: {message}")]
158    #[diagnostic(
159        code(E115),
160        help(
161            "Check that .changeset/pre.json is readable. Delete the file and re-run `callisto pre enter` to recover."
162        )
163    )]
164    PreJsonRead { message: String },
165
166    #[error("package `{package}` declares platform targets via both `{napi_source}` and `{maturin_source}`; only one source is allowed")]
167    #[diagnostic(
168        code(E118),
169        help("Remove one of the two target declarations -- either napi.targets in package.json or [tool.maturin].targets in pyproject.toml -- from the package's manifest.")
170    )]
171    ConflictingPlatformTargetSources {
172        package: PackageId,
173        napi_source: &'static str,
174        maturin_source: &'static str,
175    },
176
177    #[error(
178        "package `{package}` configures publish-to target `{target}` (ecosystem `{}`), but its detected ecosystem is `{}`",
179        .target_ecosystem.prefix(),
180        .package_ecosystems.iter().map(|e| e.prefix()).collect::<Vec<_>>().join(", ")
181    )]
182    #[diagnostic(
183        code(E119),
184        help("Remove the mismatched target from publish-to, or fix the [[package]]/[[package-set]] rule so it only matches packages in that ecosystem.")
185    )]
186    PublishTargetEcosystemMismatch {
187        package: PackageId,
188        target: String,
189        target_ecosystem: Ecosystem,
190        package_ecosystems: Vec<Ecosystem>,
191    },
192
193    #[error(
194        "package `{package}` sets `publishConfig.registry` to `{url}`, which is not an operator-approved npm registry"
195    )]
196    #[diagnostic(
197        code(E120),
198        help(
199            "`publishConfig.registry` in package.json is manifest-controlled data (a PR author \
200             can set it in their own package.json), not operator config, so it is never trusted \
201             verbatim as a publish destination. The URL must use the `https` scheme and must \
202             exactly match a `url` configured on an `npm`-kind entry in `[registries]` in \
203             callisto.toml. Add the registry there if it is a legitimate private registry, or \
204             remove the override from package.json."
205        )
206    )]
207    UntrustedNpmRegistry { package: PackageId, url: String },
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use callisto_vcs::VcsError;
214
215    /// Spec: GraphError::Vcs must be transparent — wrapping a VcsError must
216    /// not add any prefix (e.g. "vcs error: ") to the display message.
217    /// Before the fix, format!("{err}") produces "vcs error: <inner>".
218    #[test]
219    fn graph_error_vcs_is_transparent_no_prefix() {
220        let inner = VcsError::Git("some git error".to_string());
221        let expected_msg = format!("{inner}");
222        let graph_err = GraphError::Vcs(inner);
223        assert_eq!(
224            format!("{graph_err}"),
225            expected_msg,
226            "GraphError::Vcs must be transparent (no 'vcs error: ' prefix)"
227        );
228    }
229
230    /// AC-017 (message shape): ConflictingPlatformTargetSources must name the
231    /// package and both source field names in its Display text, and carry
232    /// diagnostic code E118 with help text pointing at the fix.
233    #[test]
234    fn conflicting_platform_target_sources_message_names_package_and_both_sources() {
235        use callisto_model::PackageId;
236
237        let err = GraphError::ConflictingPlatformTargetSources {
238            package: PackageId::Bare("native-mod".to_string()),
239            napi_source: "napi.targets",
240            maturin_source: "[tool.maturin].targets",
241        };
242        let msg = format!("{err}");
243        assert!(msg.contains("native-mod"), "message must name the package: {msg}");
244        assert!(msg.contains("napi.targets"), "message must name napi_source: {msg}");
245        assert!(
246            msg.contains("[tool.maturin].targets"),
247            "message must name maturin_source: {msg}"
248        );
249    }
250}
251
252#[derive(Clone, Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
253#[non_exhaustive]
254pub enum ConfigError {
255    #[error("failed to read `{path}`: {message}")]
256    #[diagnostic(code(E110))]
257    Read { path: PathBuf, message: String },
258
259    #[error("`{path}` is not valid TOML: {message}")]
260    #[diagnostic(code(E111), help("Verify callisto.toml TOML syntax formatting."))]
261    ParseToml { path: PathBuf, message: String },
262
263    #[error("[[package-set]] `{pattern}` matched no packages")]
264    PackageSetMatchedNothing { pattern: String },
265
266    #[error("[[package]] `{pattern}` matched no package")]
267    PackageMatchedNothing { pattern: String },
268
269    #[error("package `{package}` is claimed by more than one [[package-set]]: {}", .patterns.join(", "))]
270    OverlappingPackageSets { package: String, patterns: Vec<String> },
271
272    #[error("group `{group}` and group `{other}` both list `{member}`")]
273    ConflictingGroupNames {
274        group: GroupName,
275        other: GroupName,
276        member: String,
277    },
278
279    #[error("group `{group}` has no members")]
280    EmptyGroup { group: GroupName },
281
282    #[error("duplicate group name `{group}`")]
283    DuplicateGroupName { group: GroupName },
284
285    #[error("`publish-to` names registry key `{key}`, which no [registries.*] block defines")]
286    UnknownRegistry { key: String },
287
288    #[error("`{path}` sets unknown callisto key `{key}`")]
289    UnknownKey { path: PathBuf, key: String },
290
291    #[error("`cascade.bump-severity` is `{found}`; expected `patch` or `minor`")]
292    InvalidBumpSeverity { found: String },
293
294    #[error("`pre-major-inference` is `{found}`; expected `off`, `conservative`, or `conservative-feat`")]
295    InvalidPreMajorInference { found: String },
296
297    #[error(
298        "changesets.dir `{dir}` is an absolute path or contains `..` path components and would escape the workspace root"
299    )]
300    #[diagnostic(
301        code(E116),
302        help("Use a forward-slash-separated path relative to the workspace root that is not absolute and does not contain '..' components.")
303    )]
304    InvalidChangesetsDir { dir: String },
305
306    #[error("`changelog = \"{value}\"` on `{pattern}` is an absolute path or contains `..` path components and would escape the workspace root")]
307    #[diagnostic(
308        code(E113),
309        help("Use a forward-slash-separated path relative to the package root that does not contain '..' components.")
310    )]
311    InvalidChangelogPath { pattern: String, value: String },
312
313    #[error(transparent)]
314    Tag(#[from] TagTemplateError),
315
316    #[error(transparent)]
317    VersionParse(#[from] VersionParseError),
318}