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