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