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 {
91        source_name: String,
92        path: PathBuf,
93    },
94
95    #[error("resolution failed: {0}")]
96    Resolution(#[from] ResolutionError),
97
98    #[error("merge conflict in {path}")]
99    Conflict { path: String },
100
101    #[error("{item} is provided by both `{source_a}` and `{source_b}`")]
102    Collision {
103        item: String,
104        source_a: String,
105        source_b: String,
106    },
107
108    #[error("validation: {0}")]
109    Validation(#[from] ValidationError),
110
111    #[error("invalid request: {message}")]
112    InvalidRequest { message: String },
113
114    #[error("frozen violation: {message}")]
115    FrozenViolation { message: String },
116
117    #[error(
118        "locked commit {commit} is no longer reachable in {url} — the tag may have been force-pushed"
119    )]
120    LockedCommitUnreachable { commit: String, url: String },
121
122    /// Link operation error — conflict, missing target, bad symlink.
123    #[error("link error: {target}: {message}")]
124    Link {
125        target: String,
126        message: String,
127    },
128
129    #[error("I/O error: {0}")]
130    Io(#[from] std::io::Error),
131
132    #[error("HTTP error: {url} — {status}: {message}")]
133    Http {
134        url: String,
135        status: u16,
136        message: String,
137    },
138
139    #[error("git command failed: `{command}` — {message}")]
140    GitCli { command: String, message: String },
141}
142
143impl MarsError {
144    /// Map error variants to CLI exit codes.
145    ///
146    /// - 1: sync completed with unresolved conflicts
147    /// - 2: resolution/validation/config error
148    /// - 3: source, I/O, HTTP, or git CLI error
149    pub fn exit_code(&self) -> i32 {
150        match self {
151            MarsError::Conflict { .. } => 1,
152            MarsError::Link { .. }
153            | MarsError::Config(_)
154            | MarsError::Lock(_)
155            | MarsError::Resolution(_)
156            | MarsError::Collision { .. }
157            | MarsError::Validation(_)
158            | MarsError::InvalidRequest { .. }
159            | MarsError::FrozenViolation { .. }
160            | MarsError::LockedCommitUnreachable { .. } => 2,
161            MarsError::Source { .. }
162            | MarsError::UnmanagedCollision { .. }
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::Io(std::io::Error::new(
254                    std::io::ErrorKind::PermissionDenied,
255                    "denied",
256                )),
257                3,
258            ),
259            (
260                MarsError::Http {
261                    url: "https://example.com/archive.tar.gz".to_string(),
262                    status: 503,
263                    message: "service unavailable".to_string(),
264                },
265                3,
266            ),
267            (
268                MarsError::GitCli {
269                    command: "git ls-remote --tags https://example.com/repo".to_string(),
270                    message: "fatal: repository not found".to_string(),
271                },
272                3,
273            ),
274        ];
275
276        for (err, expected) in cases {
277            assert_eq!(
278                err.exit_code(),
279                expected,
280                "unexpected exit code for error: {err}"
281            );
282        }
283    }
284}