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