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