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