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("I/O error: {0}")]
124    Io(#[from] std::io::Error),
125
126    #[error("HTTP error: {url} — {status}: {message}")]
127    Http {
128        url: String,
129        status: u16,
130        message: String,
131    },
132
133    #[error("git command failed: `{command}` — {message}")]
134    GitCli { command: String, message: String },
135}
136
137impl MarsError {
138    /// Map error variants to CLI exit codes.
139    ///
140    /// - 1: sync completed with unresolved conflicts
141    /// - 2: resolution/validation/config error
142    /// - 3: source, I/O, HTTP, or git CLI error
143    pub fn exit_code(&self) -> i32 {
144        match self {
145            MarsError::Conflict { .. } => 1,
146            MarsError::Link { .. }
147            | MarsError::Config(_)
148            | MarsError::Lock(_)
149            | MarsError::Resolution(_)
150            | MarsError::Collision { .. }
151            | MarsError::Validation(_)
152            | MarsError::InvalidRequest { .. }
153            | MarsError::FrozenViolation { .. }
154            | MarsError::LockedCommitUnreachable { .. } => 2,
155            MarsError::Source { .. }
156            | MarsError::UnmanagedCollision { .. }
157            | MarsError::Io(_)
158            | MarsError::Http { .. }
159            | MarsError::GitCli { .. } => 3,
160        }
161    }
162}
163
164pub type Result<T> = std::result::Result<T, MarsError>;
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn mars_error_exit_codes_match_spec() {
172        let cases = vec![
173            (
174                MarsError::Conflict {
175                    path: "agents/reviewer.md".to_string(),
176                },
177                1,
178            ),
179            (
180                MarsError::Config(ConfigError::Invalid {
181                    message: "bad config".to_string(),
182                }),
183                2,
184            ),
185            (
186                MarsError::Lock(LockError::Corrupt {
187                    message: "bad lock".to_string(),
188                }),
189                2,
190            ),
191            (
192                MarsError::Resolution(ResolutionError::SourceNotFound {
193                    name: "missing".to_string(),
194                }),
195                2,
196            ),
197            (
198                MarsError::Collision {
199                    item: "coder".to_string(),
200                    source_a: "base".to_string(),
201                    source_b: "custom".to_string(),
202                },
203                2,
204            ),
205            (MarsError::Validation(ValidationError::UnresolvableRefs), 2),
206            (
207                MarsError::InvalidRequest {
208                    message: "bad flag combination".to_string(),
209                },
210                2,
211            ),
212            (
213                MarsError::FrozenViolation {
214                    message: "lock file would change but --frozen is set".to_string(),
215                },
216                2,
217            ),
218            (
219                MarsError::LockedCommitUnreachable {
220                    commit: "abc123".to_string(),
221                    url: "https://example.com/repo.git".to_string(),
222                },
223                2,
224            ),
225            (
226                MarsError::Link {
227                    target: ".claude".to_string(),
228                    message: "conflicts found".to_string(),
229                },
230                2,
231            ),
232            (
233                MarsError::Source {
234                    source_name: "origin".to_string(),
235                    message: "network failed".to_string(),
236                },
237                3,
238            ),
239            (
240                MarsError::UnmanagedCollision {
241                    source_name: "origin".to_string(),
242                    path: PathBuf::from("agents/coder.md"),
243                },
244                3,
245            ),
246            (
247                MarsError::Io(std::io::Error::new(
248                    std::io::ErrorKind::PermissionDenied,
249                    "denied",
250                )),
251                3,
252            ),
253            (
254                MarsError::Http {
255                    url: "https://example.com/archive.tar.gz".to_string(),
256                    status: 503,
257                    message: "service unavailable".to_string(),
258                },
259                3,
260            ),
261            (
262                MarsError::GitCli {
263                    command: "git ls-remote --tags https://example.com/repo".to_string(),
264                    message: "fatal: repository not found".to_string(),
265                },
266                3,
267            ),
268        ];
269
270        for (err, expected) in cases {
271            assert_eq!(
272                err.exit_code(),
273                expected,
274                "unexpected exit code for error: {err}"
275            );
276        }
277    }
278}