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