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