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("I/O error: {0}")]
124 Io(#[from] std::io::Error),
125
126 #[error("HTTP error: {url} — {status}: {message}")]
127 Http {
128 url: String,
129 status: u16,
130 message: String,
131 },
132
133 #[error("git command failed: `{command}` — {message}")]
134 GitCli { command: String, message: String },
135}
136
137impl MarsError {
138 pub fn exit_code(&self) -> i32 {
144 match self {
145 MarsError::Conflict { .. } => 1,
146 MarsError::Link { .. }
147 | MarsError::Config(_)
148 | MarsError::Lock(_)
149 | MarsError::Resolution(_)
150 | MarsError::Collision { .. }
151 | MarsError::Validation(_)
152 | MarsError::InvalidRequest { .. }
153 | MarsError::FrozenViolation { .. }
154 | MarsError::LockedCommitUnreachable { .. } => 2,
155 MarsError::Source { .. }
156 | MarsError::UnmanagedCollision { .. }
157 | MarsError::Io(_)
158 | MarsError::Http { .. }
159 | MarsError::GitCli { .. } => 3,
160 }
161 }
162}
163
164pub type Result<T> = std::result::Result<T, MarsError>;
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn mars_error_exit_codes_match_spec() {
172 let cases = vec![
173 (
174 MarsError::Conflict {
175 path: "agents/reviewer.md".to_string(),
176 },
177 1,
178 ),
179 (
180 MarsError::Config(ConfigError::Invalid {
181 message: "bad config".to_string(),
182 }),
183 2,
184 ),
185 (
186 MarsError::Lock(LockError::Corrupt {
187 message: "bad lock".to_string(),
188 }),
189 2,
190 ),
191 (
192 MarsError::Resolution(ResolutionError::SourceNotFound {
193 name: "missing".to_string(),
194 }),
195 2,
196 ),
197 (
198 MarsError::Collision {
199 item: "coder".to_string(),
200 source_a: "base".to_string(),
201 source_b: "custom".to_string(),
202 },
203 2,
204 ),
205 (MarsError::Validation(ValidationError::UnresolvableRefs), 2),
206 (
207 MarsError::InvalidRequest {
208 message: "bad flag combination".to_string(),
209 },
210 2,
211 ),
212 (
213 MarsError::FrozenViolation {
214 message: "lock file would change but --frozen is set".to_string(),
215 },
216 2,
217 ),
218 (
219 MarsError::LockedCommitUnreachable {
220 commit: "abc123".to_string(),
221 url: "https://example.com/repo.git".to_string(),
222 },
223 2,
224 ),
225 (
226 MarsError::Link {
227 target: ".claude".to_string(),
228 message: "conflicts found".to_string(),
229 },
230 2,
231 ),
232 (
233 MarsError::Source {
234 source_name: "origin".to_string(),
235 message: "network failed".to_string(),
236 },
237 3,
238 ),
239 (
240 MarsError::UnmanagedCollision {
241 source_name: "origin".to_string(),
242 path: PathBuf::from("agents/coder.md"),
243 },
244 3,
245 ),
246 (
247 MarsError::Io(std::io::Error::new(
248 std::io::ErrorKind::PermissionDenied,
249 "denied",
250 )),
251 3,
252 ),
253 (
254 MarsError::Http {
255 url: "https://example.com/archive.tar.gz".to_string(),
256 status: 503,
257 message: "service unavailable".to_string(),
258 },
259 3,
260 ),
261 (
262 MarsError::GitCli {
263 command: "git ls-remote --tags https://example.com/repo".to_string(),
264 message: "fatal: repository not found".to_string(),
265 },
266 3,
267 ),
268 ];
269
270 for (err, expected) in cases {
271 assert_eq!(
272 err.exit_code(),
273 expected,
274 "unexpected exit code for error: {err}"
275 );
276 }
277 }
278}