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