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("source error: {source_name}: refusing to overwrite unmanaged path `{}`", path.display())]
90 UnmanagedCollision { source_name: String, path: PathBuf },
91
92 #[error("resolution failed: {0}")]
93 Resolution(#[from] ResolutionError),
94
95 #[error("merge conflict in {path}")]
96 Conflict { path: String },
97
98 #[error("{item} is provided by both `{source_a}` and `{source_b}`")]
99 Collision {
100 item: String,
101 source_a: String,
102 source_b: String,
103 },
104
105 #[error("validation: {0}")]
106 Validation(#[from] ValidationError),
107
108 #[error("invalid request: {message}")]
109 InvalidRequest { message: String },
110
111 #[error("frozen violation: {message}")]
112 FrozenViolation { message: String },
113
114 #[error(
115 "locked commit {commit} is no longer reachable in {url} — the tag may have been force-pushed"
116 )]
117 LockedCommitUnreachable { commit: String, url: String },
118
119 #[error("link error: {target}: {message}")]
121 Link { target: String, message: String },
122
123 #[error(
124 "models cache is empty and cannot be refreshed: {reason}. Run `mars models refresh` to populate it."
125 )]
126 ModelCacheUnavailable { reason: String },
127
128 #[error("I/O error: {0}")]
129 Io(#[from] std::io::Error),
130
131 #[error("HTTP error: {url} — {status}: {message}")]
132 Http {
133 url: String,
134 status: u16,
135 message: String,
136 },
137
138 #[error("git command failed: `{command}` — {message}")]
139 GitCli { command: String, message: String },
140}
141
142impl MarsError {
143 pub fn exit_code(&self) -> i32 {
149 match self {
150 MarsError::Conflict { .. } => 1,
151 MarsError::Link { .. }
152 | MarsError::Config(_)
153 | MarsError::Lock(_)
154 | MarsError::Resolution(_)
155 | MarsError::Collision { .. }
156 | MarsError::Validation(_)
157 | MarsError::InvalidRequest { .. }
158 | MarsError::FrozenViolation { .. }
159 | MarsError::LockedCommitUnreachable { .. } => 2,
160 MarsError::Source { .. }
161 | MarsError::UnmanagedCollision { .. }
162 | MarsError::ModelCacheUnavailable { .. }
163 | MarsError::Io(_)
164 | MarsError::Http { .. }
165 | MarsError::GitCli { .. } => 3,
166 }
167 }
168}
169
170pub type Result<T> = std::result::Result<T, MarsError>;
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn mars_error_exit_codes_match_spec() {
178 let cases = vec![
179 (
180 MarsError::Conflict {
181 path: "agents/reviewer.md".to_string(),
182 },
183 1,
184 ),
185 (
186 MarsError::Config(ConfigError::Invalid {
187 message: "bad config".to_string(),
188 }),
189 2,
190 ),
191 (
192 MarsError::Lock(LockError::Corrupt {
193 message: "bad lock".to_string(),
194 }),
195 2,
196 ),
197 (
198 MarsError::Resolution(ResolutionError::SourceNotFound {
199 name: "missing".to_string(),
200 }),
201 2,
202 ),
203 (
204 MarsError::Collision {
205 item: "coder".to_string(),
206 source_a: "base".to_string(),
207 source_b: "custom".to_string(),
208 },
209 2,
210 ),
211 (MarsError::Validation(ValidationError::UnresolvableRefs), 2),
212 (
213 MarsError::InvalidRequest {
214 message: "bad flag combination".to_string(),
215 },
216 2,
217 ),
218 (
219 MarsError::FrozenViolation {
220 message: "lock file would change but --frozen is set".to_string(),
221 },
222 2,
223 ),
224 (
225 MarsError::LockedCommitUnreachable {
226 commit: "abc123".to_string(),
227 url: "https://example.com/repo.git".to_string(),
228 },
229 2,
230 ),
231 (
232 MarsError::Link {
233 target: ".claude".to_string(),
234 message: "conflicts found".to_string(),
235 },
236 2,
237 ),
238 (
239 MarsError::Source {
240 source_name: "origin".to_string(),
241 message: "network failed".to_string(),
242 },
243 3,
244 ),
245 (
246 MarsError::UnmanagedCollision {
247 source_name: "origin".to_string(),
248 path: PathBuf::from("agents/coder.md"),
249 },
250 3,
251 ),
252 (
253 MarsError::ModelCacheUnavailable {
254 reason: "MARS_OFFLINE is set and no cached catalog is available".to_string(),
255 },
256 3,
257 ),
258 (
259 MarsError::Io(std::io::Error::new(
260 std::io::ErrorKind::PermissionDenied,
261 "denied",
262 )),
263 3,
264 ),
265 (
266 MarsError::Http {
267 url: "https://example.com/archive.tar.gz".to_string(),
268 status: 503,
269 message: "service unavailable".to_string(),
270 },
271 3,
272 ),
273 (
274 MarsError::GitCli {
275 command: "git ls-remote --tags https://example.com/repo".to_string(),
276 message: "fatal: repository not found".to_string(),
277 },
278 3,
279 ),
280 ];
281
282 for (err, expected) in cases {
283 assert_eq!(
284 err.exit_code(),
285 expected,
286 "unexpected exit code for error: {err}"
287 );
288 }
289 }
290}