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