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