Skip to main content

mars_agents/
error.rs

1use std::path::PathBuf;
2
3/// Config-level errors
4#[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/// Lock file errors
23#[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/// Resolution errors
36#[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/// Validation errors
67#[derive(Debug, thiserror::Error)]
68pub enum ValidationError {
69    #[error("unresolvable skill references found")]
70    UnresolvableRefs,
71}
72
73/// Top-level error type aggregating all module errors
74#[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    /// Sync refused to overwrite a file/directory not tracked in mars.lock.
152    #[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    /// Link operation error — conflict, missing target, or invalid link metadata.
183    #[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    /// Map error variants to CLI exit codes.
207    ///
208    /// - 1: sync completed with unresolved conflicts
209    /// - 2: resolution/validation/config error
210    /// - 3: source, I/O, HTTP, or git CLI error
211    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}