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