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 "config error: invalid config: no linked harness available for model `{model_token}` — {detail}; installed harnesses: {installed_harnesses}"
214 )]
215 LinkedHarnessExhausted {
216 model_token: String,
217 detail: String,
218 installed_harnesses: String,
219 },
220
221 #[error(
222 "config error: invalid config: no harness available for model `{model_token}` — {detail}; installed harnesses: {installed_harnesses}"
223 )]
224 HarnessUnavailable {
225 model_token: String,
226 detail: String,
227 installed_harnesses: String,
228 },
229
230 #[error(
231 "locked commit {commit} is no longer reachable in {url} — the tag may have been force-pushed"
232 )]
233 LockedCommitUnreachable { commit: String, url: String },
234
235 #[error("(internal: resolution restart needed for `{package}`)")]
240 ResolutionRestartNeeded { package: String },
241
242 #[error("link error: {target}: {message}")]
244 Link { target: String, message: String },
245
246 #[error(
247 "models cache is empty and cannot be refreshed: {reason}. Run `{cmd}` to populate it.",
248 cmd = managed_cmd("mars models refresh"),
249 )]
250 ModelCacheUnavailable { reason: String },
251
252 #[error("{operation} failed for {}: {source}", path.display())]
253 Io {
254 operation: String,
255 path: PathBuf,
256 #[source]
257 source: std::io::Error,
258 },
259
260 #[error("HTTP error: {url} — {status}: {message}")]
261 Http {
262 url: String,
263 status: u16,
264 message: String,
265 },
266
267 #[error("git command failed: `{command}` — {message}")]
268 GitCli { command: String, message: String },
269
270 #[error("internal error: {0}")]
271 Internal(String),
272}
273
274impl MarsError {
275 pub fn exit_code(&self) -> i32 {
281 match self {
282 MarsError::Conflict { .. } => 1,
283 MarsError::Link { .. }
284 | MarsError::Config(_)
285 | MarsError::Lock(_)
286 | MarsError::Resolution(_)
287 | MarsError::Collision { .. }
288 | MarsError::Validation(_)
289 | MarsError::InvalidRequest { .. }
290 | MarsError::FrozenViolation { .. }
291 | MarsError::LinkedHarnessExhausted { .. }
292 | MarsError::HarnessUnavailable { .. }
293 | MarsError::LockedCommitUnreachable { .. } => 2,
294 MarsError::Source { .. }
295 | MarsError::SubpathTraversal { .. }
296 | MarsError::SubpathMissing { .. }
297 | MarsError::SubpathNotDirectory { .. }
298 | MarsError::DiscoveryCollision { .. }
299 | MarsError::ManifestDeclaredPathEscape { .. }
300 | MarsError::ManifestDeclaredPathMissing { .. }
301 | MarsError::UnmanagedCollision { .. }
302 | MarsError::ModelCacheUnavailable { .. }
303 | MarsError::Io { .. }
304 | MarsError::Http { .. }
305 | MarsError::GitCli { .. }
306 | MarsError::Internal(_) => 3,
307 MarsError::ResolutionRestartNeeded { .. } => {
308 unreachable!("ResolutionRestartNeeded is an internal signal caught by resolve()")
309 }
310 }
311 }
312}
313
314impl From<std::io::Error> for MarsError {
315 fn from(source: std::io::Error) -> Self {
316 MarsError::Io {
317 operation: "I/O operation".to_string(),
318 path: PathBuf::from("<unknown>"),
319 source,
320 }
321 }
322}
323
324pub type Result<T> = std::result::Result<T, MarsError>;
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn mars_error_exit_codes_match_spec() {
332 let cases = vec![
333 (
334 MarsError::Conflict {
335 path: "agents/reviewer.md".to_string(),
336 },
337 1,
338 ),
339 (
340 MarsError::Config(ConfigError::Invalid {
341 message: "bad config".to_string(),
342 }),
343 2,
344 ),
345 (
346 MarsError::Lock(LockError::Corrupt {
347 message: "bad lock".to_string(),
348 }),
349 2,
350 ),
351 (
352 MarsError::Resolution(ResolutionError::SourceNotFound {
353 name: "missing".to_string(),
354 }),
355 2,
356 ),
357 (
358 MarsError::Collision {
359 item: "coder".to_string(),
360 source_a: "base".to_string(),
361 source_b: "custom".to_string(),
362 },
363 2,
364 ),
365 (MarsError::Validation(ValidationError::UnresolvableRefs), 2),
366 (
367 MarsError::InvalidRequest {
368 message: "bad flag combination".to_string(),
369 },
370 2,
371 ),
372 (
373 MarsError::FrozenViolation {
374 message: "lock file would change but --frozen is set".to_string(),
375 },
376 2,
377 ),
378 (
379 MarsError::LockedCommitUnreachable {
380 commit: "abc123".to_string(),
381 url: "https://example.com/repo.git".to_string(),
382 },
383 2,
384 ),
385 (
386 MarsError::Link {
387 target: ".claude".to_string(),
388 message: "conflicts found".to_string(),
389 },
390 2,
391 ),
392 (
393 MarsError::Source {
394 source_name: "origin".to_string(),
395 message: "network failed".to_string(),
396 },
397 3,
398 ),
399 (
400 MarsError::SubpathTraversal {
401 source_name: "origin".to_string(),
402 subpath: "../escape".to_string(),
403 checkout_root: PathBuf::from("/tmp/root"),
404 },
405 3,
406 ),
407 (
408 MarsError::SubpathMissing {
409 source_name: "origin".to_string(),
410 subpath: "plugins/foo".to_string(),
411 checkout_root: PathBuf::from("/tmp/root"),
412 },
413 3,
414 ),
415 (
416 MarsError::SubpathNotDirectory {
417 source_name: "origin".to_string(),
418 subpath: "plugins/foo".to_string(),
419 checkout_root: PathBuf::from("/tmp/root"),
420 },
421 3,
422 ),
423 (
424 MarsError::DiscoveryCollision {
425 source_name: "origin".to_string(),
426 kind: "skill".to_string(),
427 item_name: "plan".to_string(),
428 path_a: PathBuf::from("skills/a"),
429 path_b: PathBuf::from("skills/b"),
430 },
431 3,
432 ),
433 (
434 MarsError::ManifestDeclaredPathEscape {
435 source_name: "origin".to_string(),
436 manifest_path: "./../escape".to_string(),
437 package_root: PathBuf::from("/tmp/root"),
438 },
439 3,
440 ),
441 (
442 MarsError::ManifestDeclaredPathMissing {
443 source_name: "origin".to_string(),
444 manifest_path: "./missing".to_string(),
445 package_root: PathBuf::from("/tmp/root"),
446 },
447 3,
448 ),
449 (
450 MarsError::UnmanagedCollision {
451 source_name: "origin".to_string(),
452 path: PathBuf::from("agents/coder.md"),
453 },
454 3,
455 ),
456 (
457 MarsError::ModelCacheUnavailable {
458 reason: "MARS_OFFLINE is set and no cached catalog is available".to_string(),
459 },
460 3,
461 ),
462 (
463 MarsError::Io {
464 operation: "read file".to_string(),
465 path: PathBuf::from("/tmp/file"),
466 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
467 },
468 3,
469 ),
470 (
471 MarsError::Http {
472 url: "https://example.com/archive.tar.gz".to_string(),
473 status: 503,
474 message: "service unavailable".to_string(),
475 },
476 3,
477 ),
478 (
479 MarsError::GitCli {
480 command: "git ls-remote --tags https://example.com/repo".to_string(),
481 message: "fatal: repository not found".to_string(),
482 },
483 3,
484 ),
485 ];
486
487 for (err, expected) in cases {
488 assert_eq!(
489 err.exit_code(),
490 expected,
491 "unexpected exit code for error: {err}"
492 );
493 }
494 }
495}