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