Skip to main content

mars_agents/
error.rs

1use std::path::PathBuf;
2
3/// Config-level errors
4#[derive(Debug, thiserror::Error)]
5pub enum ConfigError {
6    #[error("config file not found: {path}")]
7    NotFound { path: PathBuf },
8
9    #[error("invalid config: {message}")]
10    Invalid { message: String },
11
12    #[error("source `{name}` uses both agents/skills and exclude — pick one")]
13    ConflictingFilters { name: String },
14
15    #[error("parse error: {0}")]
16    Parse(#[from] toml::de::Error),
17
18    #[error("I/O error: {0}")]
19    Io(#[from] std::io::Error),
20}
21
22/// Lock file errors
23#[derive(Debug, thiserror::Error)]
24pub enum LockError {
25    #[error("lock file corrupt: {message}")]
26    Corrupt { message: String },
27
28    #[error("parse error: {0}")]
29    Parse(#[from] toml::de::Error),
30
31    #[error("I/O error: {0}")]
32    Io(#[from] std::io::Error),
33}
34
35/// Resolution errors
36#[derive(Debug, thiserror::Error)]
37pub enum ResolutionError {
38    #[error("version conflict for `{name}`: {message}")]
39    VersionConflict { name: String, message: String },
40
41    #[error(
42        "duplicate source identity: `{existing_name}` and `{duplicate_name}` both resolve to `{source_id}`"
43    )]
44    DuplicateSourceIdentity {
45        existing_name: String,
46        duplicate_name: String,
47        source_id: String,
48    },
49
50    #[error(
51        "source `{name}` was referenced with conflicting identities: existing `{existing}`, incoming `{incoming}`"
52    )]
53    SourceIdentityMismatch {
54        name: String,
55        existing: String,
56        incoming: String,
57    },
58
59    #[error("cycle detected: {chain}")]
60    Cycle { chain: String },
61
62    #[error("source not found: {name}")]
63    SourceNotFound { name: String },
64}
65
66/// Validation errors
67#[derive(Debug, thiserror::Error)]
68pub enum ValidationError {
69    #[error("unresolvable skill references found")]
70    UnresolvableRefs,
71}
72
73/// Top-level error type aggregating all module errors
74#[derive(Debug, thiserror::Error)]
75pub enum MarsError {
76    #[error("config error: {0}")]
77    Config(#[from] ConfigError),
78
79    #[error("lock error: {0}")]
80    Lock(#[from] LockError),
81
82    #[error("source error: {source_name}: {message}")]
83    Source {
84        source_name: String,
85        message: String,
86    },
87
88    /// Sync refused to overwrite a file/directory not tracked in mars.lock.
89    #[error("source error: {source_name}: refusing to overwrite unmanaged path `{}`", path.display())]
90    UnmanagedCollision { source_name: String, path: PathBuf },
91
92    #[error("resolution failed: {0}")]
93    Resolution(#[from] ResolutionError),
94
95    #[error("merge conflict in {path}")]
96    Conflict { path: String },
97
98    #[error("{item} is provided by both `{source_a}` and `{source_b}`")]
99    Collision {
100        item: String,
101        source_a: String,
102        source_b: String,
103    },
104
105    #[error("validation: {0}")]
106    Validation(#[from] ValidationError),
107
108    #[error("invalid request: {message}")]
109    InvalidRequest { message: String },
110
111    #[error("frozen violation: {message}")]
112    FrozenViolation { message: String },
113
114    #[error(
115        "locked commit {commit} is no longer reachable in {url} — the tag may have been force-pushed"
116    )]
117    LockedCommitUnreachable { commit: String, url: String },
118
119    /// Link operation error — conflict, missing target, bad symlink.
120    #[error("link error: {target}: {message}")]
121    Link { target: String, message: String },
122
123    #[error(
124        "models cache is empty and cannot be refreshed: {reason}. Run `mars models refresh` to populate it."
125    )]
126    ModelCacheUnavailable { reason: String },
127
128    #[error("I/O error: {0}")]
129    Io(#[from] std::io::Error),
130
131    #[error("HTTP error: {url} — {status}: {message}")]
132    Http {
133        url: String,
134        status: u16,
135        message: String,
136    },
137
138    #[error("git command failed: `{command}` — {message}")]
139    GitCli { command: String, message: String },
140}
141
142impl MarsError {
143    /// Map error variants to CLI exit codes.
144    ///
145    /// - 1: sync completed with unresolved conflicts
146    /// - 2: resolution/validation/config error
147    /// - 3: source, I/O, HTTP, or git CLI error
148    pub fn exit_code(&self) -> i32 {
149        match self {
150            MarsError::Conflict { .. } => 1,
151            MarsError::Link { .. }
152            | MarsError::Config(_)
153            | MarsError::Lock(_)
154            | MarsError::Resolution(_)
155            | MarsError::Collision { .. }
156            | MarsError::Validation(_)
157            | MarsError::InvalidRequest { .. }
158            | MarsError::FrozenViolation { .. }
159            | MarsError::LockedCommitUnreachable { .. } => 2,
160            MarsError::Source { .. }
161            | MarsError::UnmanagedCollision { .. }
162            | MarsError::ModelCacheUnavailable { .. }
163            | MarsError::Io(_)
164            | MarsError::Http { .. }
165            | MarsError::GitCli { .. } => 3,
166        }
167    }
168}
169
170pub type Result<T> = std::result::Result<T, MarsError>;
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn mars_error_exit_codes_match_spec() {
178        let cases = vec![
179            (
180                MarsError::Conflict {
181                    path: "agents/reviewer.md".to_string(),
182                },
183                1,
184            ),
185            (
186                MarsError::Config(ConfigError::Invalid {
187                    message: "bad config".to_string(),
188                }),
189                2,
190            ),
191            (
192                MarsError::Lock(LockError::Corrupt {
193                    message: "bad lock".to_string(),
194                }),
195                2,
196            ),
197            (
198                MarsError::Resolution(ResolutionError::SourceNotFound {
199                    name: "missing".to_string(),
200                }),
201                2,
202            ),
203            (
204                MarsError::Collision {
205                    item: "coder".to_string(),
206                    source_a: "base".to_string(),
207                    source_b: "custom".to_string(),
208                },
209                2,
210            ),
211            (MarsError::Validation(ValidationError::UnresolvableRefs), 2),
212            (
213                MarsError::InvalidRequest {
214                    message: "bad flag combination".to_string(),
215                },
216                2,
217            ),
218            (
219                MarsError::FrozenViolation {
220                    message: "lock file would change but --frozen is set".to_string(),
221                },
222                2,
223            ),
224            (
225                MarsError::LockedCommitUnreachable {
226                    commit: "abc123".to_string(),
227                    url: "https://example.com/repo.git".to_string(),
228                },
229                2,
230            ),
231            (
232                MarsError::Link {
233                    target: ".claude".to_string(),
234                    message: "conflicts found".to_string(),
235                },
236                2,
237            ),
238            (
239                MarsError::Source {
240                    source_name: "origin".to_string(),
241                    message: "network failed".to_string(),
242                },
243                3,
244            ),
245            (
246                MarsError::UnmanagedCollision {
247                    source_name: "origin".to_string(),
248                    path: PathBuf::from("agents/coder.md"),
249                },
250                3,
251            ),
252            (
253                MarsError::ModelCacheUnavailable {
254                    reason: "MARS_OFFLINE is set and no cached catalog is available".to_string(),
255                },
256                3,
257            ),
258            (
259                MarsError::Io(std::io::Error::new(
260                    std::io::ErrorKind::PermissionDenied,
261                    "denied",
262                )),
263                3,
264            ),
265            (
266                MarsError::Http {
267                    url: "https://example.com/archive.tar.gz".to_string(),
268                    status: 503,
269                    message: "service unavailable".to_string(),
270                },
271                3,
272            ),
273            (
274                MarsError::GitCli {
275                    command: "git ls-remote --tags https://example.com/repo".to_string(),
276                    message: "fatal: repository not found".to_string(),
277                },
278                3,
279            ),
280        ];
281
282        for (err, expected) in cases {
283            assert_eq!(
284                err.exit_code(),
285                expected,
286                "unexpected exit code for error: {err}"
287            );
288        }
289    }
290}