use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config file not found: {path}")]
NotFound { path: PathBuf },
#[error("invalid config: {message}")]
Invalid { message: String },
#[error("source `{name}` uses both agents/skills and exclude — pick one")]
ConflictingFilters { name: String },
#[error("parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum LockError {
#[error("lock file corrupt: {message}")]
Corrupt { message: String },
#[error("parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, thiserror::Error)]
pub enum ResolutionError {
#[error("version conflict for `{name}`: {message}")]
VersionConflict { name: String, message: String },
#[error(
"duplicate source identity: `{existing_name}` and `{duplicate_name}` both resolve to `{source_id}`"
)]
DuplicateSourceIdentity {
existing_name: String,
duplicate_name: String,
source_id: String,
},
#[error(
"source `{name}` was referenced with conflicting identities: existing `{existing}`, incoming `{incoming}`"
)]
SourceIdentityMismatch {
name: String,
existing: String,
incoming: String,
},
#[error("cycle detected: {chain}")]
Cycle { chain: String },
#[error("source not found: {name}")]
SourceNotFound { name: String },
}
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("unresolvable skill references found")]
UnresolvableRefs,
}
#[derive(Debug, thiserror::Error)]
pub enum MarsError {
#[error("config error: {0}")]
Config(#[from] ConfigError),
#[error("lock error: {0}")]
Lock(#[from] LockError),
#[error("source error: {source_name}: {message}")]
Source {
source_name: String,
message: String,
},
#[error("source error: {source_name}: refusing to overwrite unmanaged path `{}`", path.display())]
UnmanagedCollision { source_name: String, path: PathBuf },
#[error("resolution failed: {0}")]
Resolution(#[from] ResolutionError),
#[error("merge conflict in {path}")]
Conflict { path: String },
#[error("{item} is provided by both `{source_a}` and `{source_b}`")]
Collision {
item: String,
source_a: String,
source_b: String,
},
#[error("validation: {0}")]
Validation(#[from] ValidationError),
#[error("invalid request: {message}")]
InvalidRequest { message: String },
#[error("frozen violation: {message}")]
FrozenViolation { message: String },
#[error(
"locked commit {commit} is no longer reachable in {url} — the tag may have been force-pushed"
)]
LockedCommitUnreachable { commit: String, url: String },
#[error("link error: {target}: {message}")]
Link { target: String, message: String },
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("HTTP error: {url} — {status}: {message}")]
Http {
url: String,
status: u16,
message: String,
},
#[error("git command failed: `{command}` — {message}")]
GitCli { command: String, message: String },
}
impl MarsError {
pub fn exit_code(&self) -> i32 {
match self {
MarsError::Conflict { .. } => 1,
MarsError::Link { .. }
| MarsError::Config(_)
| MarsError::Lock(_)
| MarsError::Resolution(_)
| MarsError::Collision { .. }
| MarsError::Validation(_)
| MarsError::InvalidRequest { .. }
| MarsError::FrozenViolation { .. }
| MarsError::LockedCommitUnreachable { .. } => 2,
MarsError::Source { .. }
| MarsError::UnmanagedCollision { .. }
| MarsError::Io(_)
| MarsError::Http { .. }
| MarsError::GitCli { .. } => 3,
}
}
}
pub type Result<T> = std::result::Result<T, MarsError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mars_error_exit_codes_match_spec() {
let cases = vec![
(
MarsError::Conflict {
path: "agents/reviewer.md".to_string(),
},
1,
),
(
MarsError::Config(ConfigError::Invalid {
message: "bad config".to_string(),
}),
2,
),
(
MarsError::Lock(LockError::Corrupt {
message: "bad lock".to_string(),
}),
2,
),
(
MarsError::Resolution(ResolutionError::SourceNotFound {
name: "missing".to_string(),
}),
2,
),
(
MarsError::Collision {
item: "coder".to_string(),
source_a: "base".to_string(),
source_b: "custom".to_string(),
},
2,
),
(MarsError::Validation(ValidationError::UnresolvableRefs), 2),
(
MarsError::InvalidRequest {
message: "bad flag combination".to_string(),
},
2,
),
(
MarsError::FrozenViolation {
message: "lock file would change but --frozen is set".to_string(),
},
2,
),
(
MarsError::LockedCommitUnreachable {
commit: "abc123".to_string(),
url: "https://example.com/repo.git".to_string(),
},
2,
),
(
MarsError::Link {
target: ".claude".to_string(),
message: "conflicts found".to_string(),
},
2,
),
(
MarsError::Source {
source_name: "origin".to_string(),
message: "network failed".to_string(),
},
3,
),
(
MarsError::UnmanagedCollision {
source_name: "origin".to_string(),
path: PathBuf::from("agents/coder.md"),
},
3,
),
(
MarsError::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"denied",
)),
3,
),
(
MarsError::Http {
url: "https://example.com/archive.tar.gz".to_string(),
status: 503,
message: "service unavailable".to_string(),
},
3,
),
(
MarsError::GitCli {
command: "git ls-remote --tags https://example.com/repo".to_string(),
message: "fatal: repository not found".to_string(),
},
3,
),
];
for (err, expected) in cases {
assert_eq!(
err.exit_code(),
expected,
"unexpected exit code for error: {err}"
);
}
}
}