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
243impl MarsError {
244 pub fn exit_code(&self) -> i32 {
250 match self {
251 MarsError::Conflict { .. } => 1,
252 MarsError::Link { .. }
253 | MarsError::Config(_)
254 | MarsError::Lock(_)
255 | MarsError::Resolution(_)
256 | MarsError::Collision { .. }
257 | MarsError::Validation(_)
258 | MarsError::InvalidRequest { .. }
259 | MarsError::FrozenViolation { .. }
260 | MarsError::LockedCommitUnreachable { .. } => 2,
261 MarsError::Source { .. }
262 | MarsError::SubpathTraversal { .. }
263 | MarsError::SubpathMissing { .. }
264 | MarsError::SubpathNotDirectory { .. }
265 | MarsError::DiscoveryCollision { .. }
266 | MarsError::ManifestDeclaredPathEscape { .. }
267 | MarsError::ManifestDeclaredPathMissing { .. }
268 | MarsError::UnmanagedCollision { .. }
269 | MarsError::ModelCacheUnavailable { .. }
270 | MarsError::Io { .. }
271 | MarsError::Http { .. }
272 | MarsError::GitCli { .. } => 3,
273 }
274 }
275}
276
277impl From<std::io::Error> for MarsError {
278 fn from(source: std::io::Error) -> Self {
279 MarsError::Io {
280 operation: "I/O operation".to_string(),
281 path: PathBuf::from("<unknown>"),
282 source,
283 }
284 }
285}
286
287pub type Result<T> = std::result::Result<T, MarsError>;
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn mars_error_exit_codes_match_spec() {
295 let cases = vec![
296 (
297 MarsError::Conflict {
298 path: "agents/reviewer.md".to_string(),
299 },
300 1,
301 ),
302 (
303 MarsError::Config(ConfigError::Invalid {
304 message: "bad config".to_string(),
305 }),
306 2,
307 ),
308 (
309 MarsError::Lock(LockError::Corrupt {
310 message: "bad lock".to_string(),
311 }),
312 2,
313 ),
314 (
315 MarsError::Resolution(ResolutionError::SourceNotFound {
316 name: "missing".to_string(),
317 }),
318 2,
319 ),
320 (
321 MarsError::Collision {
322 item: "coder".to_string(),
323 source_a: "base".to_string(),
324 source_b: "custom".to_string(),
325 },
326 2,
327 ),
328 (MarsError::Validation(ValidationError::UnresolvableRefs), 2),
329 (
330 MarsError::InvalidRequest {
331 message: "bad flag combination".to_string(),
332 },
333 2,
334 ),
335 (
336 MarsError::FrozenViolation {
337 message: "lock file would change but --frozen is set".to_string(),
338 },
339 2,
340 ),
341 (
342 MarsError::LockedCommitUnreachable {
343 commit: "abc123".to_string(),
344 url: "https://example.com/repo.git".to_string(),
345 },
346 2,
347 ),
348 (
349 MarsError::Link {
350 target: ".claude".to_string(),
351 message: "conflicts found".to_string(),
352 },
353 2,
354 ),
355 (
356 MarsError::Source {
357 source_name: "origin".to_string(),
358 message: "network failed".to_string(),
359 },
360 3,
361 ),
362 (
363 MarsError::SubpathTraversal {
364 source_name: "origin".to_string(),
365 subpath: "../escape".to_string(),
366 checkout_root: PathBuf::from("/tmp/root"),
367 },
368 3,
369 ),
370 (
371 MarsError::SubpathMissing {
372 source_name: "origin".to_string(),
373 subpath: "plugins/foo".to_string(),
374 checkout_root: PathBuf::from("/tmp/root"),
375 },
376 3,
377 ),
378 (
379 MarsError::SubpathNotDirectory {
380 source_name: "origin".to_string(),
381 subpath: "plugins/foo".to_string(),
382 checkout_root: PathBuf::from("/tmp/root"),
383 },
384 3,
385 ),
386 (
387 MarsError::DiscoveryCollision {
388 source_name: "origin".to_string(),
389 kind: "skill".to_string(),
390 item_name: "plan".to_string(),
391 path_a: PathBuf::from("skills/a"),
392 path_b: PathBuf::from("skills/b"),
393 },
394 3,
395 ),
396 (
397 MarsError::ManifestDeclaredPathEscape {
398 source_name: "origin".to_string(),
399 manifest_path: "./../escape".to_string(),
400 package_root: PathBuf::from("/tmp/root"),
401 },
402 3,
403 ),
404 (
405 MarsError::ManifestDeclaredPathMissing {
406 source_name: "origin".to_string(),
407 manifest_path: "./missing".to_string(),
408 package_root: PathBuf::from("/tmp/root"),
409 },
410 3,
411 ),
412 (
413 MarsError::UnmanagedCollision {
414 source_name: "origin".to_string(),
415 path: PathBuf::from("agents/coder.md"),
416 },
417 3,
418 ),
419 (
420 MarsError::ModelCacheUnavailable {
421 reason: "MARS_OFFLINE is set and no cached catalog is available".to_string(),
422 },
423 3,
424 ),
425 (
426 MarsError::Io {
427 operation: "read file".to_string(),
428 path: PathBuf::from("/tmp/file"),
429 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
430 },
431 3,
432 ),
433 (
434 MarsError::Http {
435 url: "https://example.com/archive.tar.gz".to_string(),
436 status: 503,
437 message: "service unavailable".to_string(),
438 },
439 3,
440 ),
441 (
442 MarsError::GitCli {
443 command: "git ls-remote --tags https://example.com/repo".to_string(),
444 message: "fatal: repository not found".to_string(),
445 },
446 3,
447 ),
448 ];
449
450 for (err, expected) in cases {
451 assert_eq!(
452 err.exit_code(),
453 expected,
454 "unexpected exit code for error: {err}"
455 );
456 }
457 }
458}