1use crate::types::managed_cmd;
2use std::path::PathBuf;
3
4#[derive(Debug, thiserror::Error)]
6pub enum ConfigError {
7 #[error("config file not found: {path}")]
8 NotFound { path: PathBuf },
9
10 #[error(
11 "no mars.toml found from {} to filesystem root. Run `{cmd}` first.",
12 start.display(),
13 cmd = managed_cmd("mars init"),
14 )]
15 ProjectRootNotFound { start: PathBuf },
16
17 #[error("invalid config: {message}")]
18 Invalid { message: String },
19
20 #[error("source `{name}` uses both agents/skills and exclude — pick one")]
21 ConflictingFilters { name: String },
22
23 #[error("parse error: {0}")]
24 Parse(#[from] toml::de::Error),
25
26 #[error("I/O error: {0}")]
27 Io(#[from] std::io::Error),
28}
29
30#[derive(Debug, thiserror::Error)]
32pub enum LockError {
33 #[error("lock file corrupt: {message}")]
34 Corrupt { message: String },
35
36 #[error("parse error: {0}")]
37 Parse(#[from] toml::de::Error),
38
39 #[error("I/O error: {0}")]
40 Io(#[from] std::io::Error),
41}
42
43#[derive(Debug, thiserror::Error)]
45pub enum ResolutionError {
46 #[error("version conflict for `{name}`: {message}")]
47 VersionConflict { name: String, message: String },
48
49 #[error(
50 "version conflict for item `{item}` from package `{package}`: {existing} vs {requested} (requester chain: {chain})"
51 )]
52 ItemVersionConflict {
53 item: String,
54 package: String,
55 existing: String,
56 requested: String,
57 chain: String,
58 },
59
60 #[error(
61 "package version conflict for `{package}`: {existing} vs {requested} (requester chain: {chain})"
62 )]
63 PackageVersionConflict {
64 package: String,
65 existing: String,
66 requested: String,
67 chain: String,
68 },
69
70 #[error(
71 "skill `{skill}` not found (required by {required_by}; searched packages: {searched:?})"
72 )]
73 SkillNotFound {
74 skill: String,
75 required_by: String,
76 searched: Vec<String>,
77 },
78
79 #[error(
80 "duplicate source identity: `{existing_name}` and `{duplicate_name}` both resolve to `{source_id}`"
81 )]
82 DuplicateSourceIdentity {
83 existing_name: String,
84 duplicate_name: String,
85 source_id: String,
86 },
87
88 #[error(
89 "source `{name}` was referenced with conflicting identities: existing `{existing}`, incoming `{incoming}`"
90 )]
91 SourceIdentityMismatch {
92 name: String,
93 existing: String,
94 incoming: String,
95 },
96
97 #[error("source not found: {name}")]
98 SourceNotFound { name: String },
99}
100
101#[derive(Debug, thiserror::Error)]
103pub enum ValidationError {
104 #[error("unresolvable skill references found")]
105 UnresolvableRefs,
106}
107
108#[derive(Debug, thiserror::Error)]
110pub enum MarsError {
111 #[error("config error: {0}")]
112 Config(#[from] ConfigError),
113
114 #[error("lock error: {0}")]
115 Lock(#[from] LockError),
116
117 #[error("source error: {source_name}: {message}")]
118 Source {
119 source_name: String,
120 message: String,
121 },
122
123 #[error(
124 "source error: {source_name}: subpath `{subpath}` escapes checkout root `{}`",
125 checkout_root.display()
126 )]
127 SubpathTraversal {
128 source_name: String,
129 subpath: String,
130 checkout_root: PathBuf,
131 },
132
133 #[error(
134 "source error: {source_name}: subpath `{subpath}` does not exist under checkout root `{}`",
135 checkout_root.display()
136 )]
137 SubpathMissing {
138 source_name: String,
139 subpath: String,
140 checkout_root: PathBuf,
141 },
142
143 #[error(
144 "source error: {source_name}: subpath `{subpath}` is not a directory under checkout root `{}`",
145 checkout_root.display()
146 )]
147 SubpathNotDirectory {
148 source_name: String,
149 subpath: String,
150 checkout_root: PathBuf,
151 },
152
153 #[error(
154 "discovery collision in `{source_name}`: {kind} `{item_name}` found at `{}` and `{}`",
155 path_a.display(),
156 path_b.display()
157 )]
158 DiscoveryCollision {
159 source_name: String,
160 kind: String,
161 item_name: String,
162 path_a: PathBuf,
163 path_b: PathBuf,
164 },
165
166 #[error(
167 "source error: {source_name}: plugin manifest path `{manifest_path}` escapes package root `{}`",
168 package_root.display()
169 )]
170 ManifestDeclaredPathEscape {
171 source_name: String,
172 manifest_path: String,
173 package_root: PathBuf,
174 },
175
176 #[error(
177 "source error: {source_name}: plugin manifest path `{manifest_path}` does not exist under package root `{}`",
178 package_root.display()
179 )]
180 ManifestDeclaredPathMissing {
181 source_name: String,
182 manifest_path: String,
183 package_root: PathBuf,
184 },
185
186 #[error("source error: {source_name}: refusing to overwrite unmanaged path `{}`", path.display())]
188 UnmanagedCollision { source_name: String, path: PathBuf },
189
190 #[error("resolution failed: {0}")]
191 Resolution(#[from] ResolutionError),
192
193 #[error("merge conflict in {path}")]
194 Conflict { path: String },
195
196 #[error("{item} is provided by both `{source_a}` and `{source_b}`")]
197 Collision {
198 item: String,
199 source_a: String,
200 source_b: String,
201 },
202
203 #[error("validation: {0}")]
204 Validation(#[from] ValidationError),
205
206 #[error("invalid request: {message}")]
207 InvalidRequest { message: String },
208
209 #[error("frozen violation: {message}")]
210 FrozenViolation { message: String },
211
212 #[error(
213 "locked commit {commit} is no longer reachable in {url} — the tag may have been force-pushed"
214 )]
215 LockedCommitUnreachable { commit: String, url: String },
216
217 #[error("(internal: resolution restart needed for `{package}`)")]
222 ResolutionRestartNeeded { package: String },
223
224 #[error("link error: {target}: {message}")]
226 Link { target: String, message: String },
227
228 #[error(
229 "models cache is empty and cannot be refreshed: {reason}. Run `{cmd}` to populate it.",
230 cmd = managed_cmd("mars models refresh"),
231 )]
232 ModelCacheUnavailable { reason: String },
233
234 #[error("{operation} failed for {}: {source}", path.display())]
235 Io {
236 operation: String,
237 path: PathBuf,
238 #[source]
239 source: std::io::Error,
240 },
241
242 #[error("HTTP error: {url} — {status}: {message}")]
243 Http {
244 url: String,
245 status: u16,
246 message: String,
247 },
248
249 #[error("git command failed: `{command}` — {message}")]
250 GitCli { command: String, message: String },
251
252 #[error("internal error: {0}")]
253 Internal(String),
254}
255
256impl MarsError {
257 pub fn exit_code(&self) -> i32 {
263 match self {
264 MarsError::Conflict { .. } => 1,
265 MarsError::Link { .. }
266 | MarsError::Config(_)
267 | MarsError::Lock(_)
268 | MarsError::Resolution(_)
269 | MarsError::Collision { .. }
270 | MarsError::Validation(_)
271 | MarsError::InvalidRequest { .. }
272 | MarsError::FrozenViolation { .. }
273 | MarsError::LockedCommitUnreachable { .. } => 2,
274 MarsError::Source { .. }
275 | MarsError::SubpathTraversal { .. }
276 | MarsError::SubpathMissing { .. }
277 | MarsError::SubpathNotDirectory { .. }
278 | MarsError::DiscoveryCollision { .. }
279 | MarsError::ManifestDeclaredPathEscape { .. }
280 | MarsError::ManifestDeclaredPathMissing { .. }
281 | MarsError::UnmanagedCollision { .. }
282 | MarsError::ModelCacheUnavailable { .. }
283 | MarsError::Io { .. }
284 | MarsError::Http { .. }
285 | MarsError::GitCli { .. }
286 | MarsError::Internal(_) => 3,
287 MarsError::ResolutionRestartNeeded { .. } => {
288 unreachable!("ResolutionRestartNeeded is an internal signal caught by resolve()")
289 }
290 }
291 }
292}
293
294impl From<std::io::Error> for MarsError {
295 fn from(source: std::io::Error) -> Self {
296 MarsError::Io {
297 operation: "I/O operation".to_string(),
298 path: PathBuf::from("<unknown>"),
299 source,
300 }
301 }
302}
303
304pub type Result<T> = std::result::Result<T, MarsError>;
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn mars_error_exit_codes_match_spec() {
312 let cases = vec![
313 (
314 MarsError::Conflict {
315 path: "agents/reviewer.md".to_string(),
316 },
317 1,
318 ),
319 (
320 MarsError::Config(ConfigError::Invalid {
321 message: "bad config".to_string(),
322 }),
323 2,
324 ),
325 (
326 MarsError::Lock(LockError::Corrupt {
327 message: "bad lock".to_string(),
328 }),
329 2,
330 ),
331 (
332 MarsError::Resolution(ResolutionError::SourceNotFound {
333 name: "missing".to_string(),
334 }),
335 2,
336 ),
337 (
338 MarsError::Collision {
339 item: "coder".to_string(),
340 source_a: "base".to_string(),
341 source_b: "custom".to_string(),
342 },
343 2,
344 ),
345 (MarsError::Validation(ValidationError::UnresolvableRefs), 2),
346 (
347 MarsError::InvalidRequest {
348 message: "bad flag combination".to_string(),
349 },
350 2,
351 ),
352 (
353 MarsError::FrozenViolation {
354 message: "lock file would change but --frozen is set".to_string(),
355 },
356 2,
357 ),
358 (
359 MarsError::LockedCommitUnreachable {
360 commit: "abc123".to_string(),
361 url: "https://example.com/repo.git".to_string(),
362 },
363 2,
364 ),
365 (
366 MarsError::Link {
367 target: ".claude".to_string(),
368 message: "conflicts found".to_string(),
369 },
370 2,
371 ),
372 (
373 MarsError::Source {
374 source_name: "origin".to_string(),
375 message: "network failed".to_string(),
376 },
377 3,
378 ),
379 (
380 MarsError::SubpathTraversal {
381 source_name: "origin".to_string(),
382 subpath: "../escape".to_string(),
383 checkout_root: PathBuf::from("/tmp/root"),
384 },
385 3,
386 ),
387 (
388 MarsError::SubpathMissing {
389 source_name: "origin".to_string(),
390 subpath: "plugins/foo".to_string(),
391 checkout_root: PathBuf::from("/tmp/root"),
392 },
393 3,
394 ),
395 (
396 MarsError::SubpathNotDirectory {
397 source_name: "origin".to_string(),
398 subpath: "plugins/foo".to_string(),
399 checkout_root: PathBuf::from("/tmp/root"),
400 },
401 3,
402 ),
403 (
404 MarsError::DiscoveryCollision {
405 source_name: "origin".to_string(),
406 kind: "skill".to_string(),
407 item_name: "plan".to_string(),
408 path_a: PathBuf::from("skills/a"),
409 path_b: PathBuf::from("skills/b"),
410 },
411 3,
412 ),
413 (
414 MarsError::ManifestDeclaredPathEscape {
415 source_name: "origin".to_string(),
416 manifest_path: "./../escape".to_string(),
417 package_root: PathBuf::from("/tmp/root"),
418 },
419 3,
420 ),
421 (
422 MarsError::ManifestDeclaredPathMissing {
423 source_name: "origin".to_string(),
424 manifest_path: "./missing".to_string(),
425 package_root: PathBuf::from("/tmp/root"),
426 },
427 3,
428 ),
429 (
430 MarsError::UnmanagedCollision {
431 source_name: "origin".to_string(),
432 path: PathBuf::from("agents/coder.md"),
433 },
434 3,
435 ),
436 (
437 MarsError::ModelCacheUnavailable {
438 reason: "MARS_OFFLINE is set and no cached catalog is available".to_string(),
439 },
440 3,
441 ),
442 (
443 MarsError::Io {
444 operation: "read file".to_string(),
445 path: PathBuf::from("/tmp/file"),
446 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
447 },
448 3,
449 ),
450 (
451 MarsError::Http {
452 url: "https://example.com/archive.tar.gz".to_string(),
453 status: 503,
454 message: "service unavailable".to_string(),
455 },
456 3,
457 ),
458 (
459 MarsError::GitCli {
460 command: "git ls-remote --tags https://example.com/repo".to_string(),
461 message: "fatal: repository not found".to_string(),
462 },
463 3,
464 ),
465 ];
466
467 for (err, expected) in cases {
468 assert_eq!(
469 err.exit_code(),
470 expected,
471 "unexpected exit code for error: {err}"
472 );
473 }
474 }
475}