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