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("{operation} failed for {}: {source}", path.display())]
225    Io {
226        operation: String,
227        path: PathBuf,
228        #[source]
229        source: std::io::Error,
230    },
231
232    #[error("HTTP error: {url} — {status}: {message}")]
233    Http {
234        url: String,
235        status: u16,
236        message: String,
237    },
238
239    #[error("git command failed: `{command}` — {message}")]
240    GitCli { command: String, message: String },
241
242    #[error("internal error: {0}")]
243    Internal(String),
244}
245
246impl MarsError {
247    /// Map error variants to CLI exit codes.
248    ///
249    /// - 1: sync completed with unresolved conflicts
250    /// - 2: resolution/validation/config error
251    /// - 3: source, I/O, HTTP, or git CLI error
252    pub fn exit_code(&self) -> i32 {
253        match self {
254            MarsError::Conflict { .. } => 1,
255            MarsError::Link { .. }
256            | MarsError::Config(_)
257            | MarsError::Lock(_)
258            | MarsError::Resolution(_)
259            | MarsError::Collision { .. }
260            | MarsError::Validation(_)
261            | MarsError::InvalidRequest { .. }
262            | MarsError::FrozenViolation { .. }
263            | MarsError::LockedCommitUnreachable { .. } => 2,
264            MarsError::Source { .. }
265            | MarsError::SubpathTraversal { .. }
266            | MarsError::SubpathMissing { .. }
267            | MarsError::SubpathNotDirectory { .. }
268            | MarsError::DiscoveryCollision { .. }
269            | MarsError::ManifestDeclaredPathEscape { .. }
270            | MarsError::ManifestDeclaredPathMissing { .. }
271            | MarsError::UnmanagedCollision { .. }
272            | MarsError::ModelCacheUnavailable { .. }
273            | MarsError::Io { .. }
274            | MarsError::Http { .. }
275            | MarsError::GitCli { .. }
276            | MarsError::Internal(_) => 3,
277        }
278    }
279}
280
281impl From<std::io::Error> for MarsError {
282    fn from(source: std::io::Error) -> Self {
283        MarsError::Io {
284            operation: "I/O operation".to_string(),
285            path: PathBuf::from("<unknown>"),
286            source,
287        }
288    }
289}
290
291pub type Result<T> = std::result::Result<T, MarsError>;
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn mars_error_exit_codes_match_spec() {
299        let cases = vec![
300            (
301                MarsError::Conflict {
302                    path: "agents/reviewer.md".to_string(),
303                },
304                1,
305            ),
306            (
307                MarsError::Config(ConfigError::Invalid {
308                    message: "bad config".to_string(),
309                }),
310                2,
311            ),
312            (
313                MarsError::Lock(LockError::Corrupt {
314                    message: "bad lock".to_string(),
315                }),
316                2,
317            ),
318            (
319                MarsError::Resolution(ResolutionError::SourceNotFound {
320                    name: "missing".to_string(),
321                }),
322                2,
323            ),
324            (
325                MarsError::Collision {
326                    item: "coder".to_string(),
327                    source_a: "base".to_string(),
328                    source_b: "custom".to_string(),
329                },
330                2,
331            ),
332            (MarsError::Validation(ValidationError::UnresolvableRefs), 2),
333            (
334                MarsError::InvalidRequest {
335                    message: "bad flag combination".to_string(),
336                },
337                2,
338            ),
339            (
340                MarsError::FrozenViolation {
341                    message: "lock file would change but --frozen is set".to_string(),
342                },
343                2,
344            ),
345            (
346                MarsError::LockedCommitUnreachable {
347                    commit: "abc123".to_string(),
348                    url: "https://example.com/repo.git".to_string(),
349                },
350                2,
351            ),
352            (
353                MarsError::Link {
354                    target: ".claude".to_string(),
355                    message: "conflicts found".to_string(),
356                },
357                2,
358            ),
359            (
360                MarsError::Source {
361                    source_name: "origin".to_string(),
362                    message: "network failed".to_string(),
363                },
364                3,
365            ),
366            (
367                MarsError::SubpathTraversal {
368                    source_name: "origin".to_string(),
369                    subpath: "../escape".to_string(),
370                    checkout_root: PathBuf::from("/tmp/root"),
371                },
372                3,
373            ),
374            (
375                MarsError::SubpathMissing {
376                    source_name: "origin".to_string(),
377                    subpath: "plugins/foo".to_string(),
378                    checkout_root: PathBuf::from("/tmp/root"),
379                },
380                3,
381            ),
382            (
383                MarsError::SubpathNotDirectory {
384                    source_name: "origin".to_string(),
385                    subpath: "plugins/foo".to_string(),
386                    checkout_root: PathBuf::from("/tmp/root"),
387                },
388                3,
389            ),
390            (
391                MarsError::DiscoveryCollision {
392                    source_name: "origin".to_string(),
393                    kind: "skill".to_string(),
394                    item_name: "plan".to_string(),
395                    path_a: PathBuf::from("skills/a"),
396                    path_b: PathBuf::from("skills/b"),
397                },
398                3,
399            ),
400            (
401                MarsError::ManifestDeclaredPathEscape {
402                    source_name: "origin".to_string(),
403                    manifest_path: "./../escape".to_string(),
404                    package_root: PathBuf::from("/tmp/root"),
405                },
406                3,
407            ),
408            (
409                MarsError::ManifestDeclaredPathMissing {
410                    source_name: "origin".to_string(),
411                    manifest_path: "./missing".to_string(),
412                    package_root: PathBuf::from("/tmp/root"),
413                },
414                3,
415            ),
416            (
417                MarsError::UnmanagedCollision {
418                    source_name: "origin".to_string(),
419                    path: PathBuf::from("agents/coder.md"),
420                },
421                3,
422            ),
423            (
424                MarsError::ModelCacheUnavailable {
425                    reason: "MARS_OFFLINE is set and no cached catalog is available".to_string(),
426                },
427                3,
428            ),
429            (
430                MarsError::Io {
431                    operation: "read file".to_string(),
432                    path: PathBuf::from("/tmp/file"),
433                    source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
434                },
435                3,
436            ),
437            (
438                MarsError::Http {
439                    url: "https://example.com/archive.tar.gz".to_string(),
440                    status: 503,
441                    message: "service unavailable".to_string(),
442                },
443                3,
444            ),
445            (
446                MarsError::GitCli {
447                    command: "git ls-remote --tags https://example.com/repo".to_string(),
448                    message: "fatal: repository not found".to_string(),
449                },
450                3,
451            ),
452        ];
453
454        for (err, expected) in cases {
455            assert_eq!(
456                err.exit_code(),
457                expected,
458                "unexpected exit code for error: {err}"
459            );
460        }
461    }
462}