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(
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/// Lock file errors
29#[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/// Resolution errors
42#[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/// Validation errors
73#[derive(Debug, thiserror::Error)]
74pub enum ValidationError {
75    #[error("unresolvable skill references found")]
76    UnresolvableRefs,
77}
78
79/// Top-level error type aggregating all module errors
80#[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    /// Sync refused to overwrite a file/directory not tracked in mars.lock.
158    #[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    /// Link operation error — conflict, missing target, or invalid link metadata.
189    #[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    /// Map error variants to CLI exit codes.
213    ///
214    /// - 1: sync completed with unresolved conflicts
215    /// - 2: resolution/validation/config error
216    /// - 3: source, I/O, HTTP, or git CLI error
217    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}