Skip to main content

mars_agents/
error.rs

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