Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0"
thiserror = "2.0.17"

[dev-dependencies]
Expand Down
151 changes: 151 additions & 0 deletions src/config/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
use std::path::PathBuf;

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("preset error: {0}")]
Preset(#[from] PresetError),
#[error("validation error: {0}")]
Validation(String),
}

#[derive(Debug, thiserror::Error)]
pub enum PresetError {
#[error("local file not found: {0}")]
LocalNotFound(PathBuf),
#[error("fetch error: {url}: {message}")]
Fetch { url: String, message: String },
#[error("invalid reference: {0}")]
InvalidReference(String),
#[error("circular reference detected: {}", .cycle.join(" → "))]
CircularReference { cycle: Vec<String> },
#[error("cache error: {0}")]
Cache(String),
}

#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;

// === ConfigError ===

#[test]
fn config_error_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let config_err: ConfigError = io_err.into();
assert_eq!(config_err.to_string(), "io error: file not found");
}

#[test]
fn config_error_from_preset_error() {
let preset_err = PresetError::InvalidReference("bad ref".to_string());
let config_err: ConfigError = preset_err.into();
assert_eq!(
config_err.to_string(),
"preset error: invalid reference: bad ref"
);
}

#[test]
fn config_error_validation() {
let error = ConfigError::Validation("rule must have exactly one action".to_string());
assert_eq!(
error.to_string(),
"validation error: rule must have exactly one action"
);
}

#[test]
fn config_error_io_has_source() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let config_err = ConfigError::Io(io_err);
let source = std::error::Error::source(&config_err);
assert!(source.is_some());
}

#[test]
fn config_error_preset_has_source() {
let preset_err = PresetError::InvalidReference("bad ref".to_string());
let config_err = ConfigError::Preset(preset_err);
let source = std::error::Error::source(&config_err);
assert!(source.is_some());
}

// === PresetError ===

#[test]
fn preset_error_local_not_found() {
let error = PresetError::LocalNotFound(std::path::PathBuf::from("/missing/file.yml"));
assert_eq!(error.to_string(), "local file not found: /missing/file.yml");
}

#[rstest]
#[case(
PresetError::Fetch {
url: "https://example.com/preset.yml".to_string(),
message: "404 Not Found".to_string(),
},
"fetch error: https://example.com/preset.yml: 404 Not Found"
)]
#[case(
PresetError::InvalidReference("github:invalid".to_string()),
"invalid reference: github:invalid"
)]
#[case(
PresetError::Cache("write failed".to_string()),
"cache error: write failed"
)]
fn preset_error_display(#[case] error: PresetError, #[case] expected: &str) {
assert_eq!(error.to_string(), expected);
}

#[test]
fn preset_error_circular_reference() {
let error = PresetError::CircularReference {
cycle: vec![
"a.yml".to_string(),
"b.yml".to_string(),
"a.yml".to_string(),
],
};
assert_eq!(
error.to_string(),
"circular reference detected: a.yml → b.yml → a.yml"
);
}

#[test]
fn preset_error_implements_std_error() {
let error: &dyn std::error::Error = &PresetError::InvalidReference("test".to_string());
assert!(error.source().is_none());
}

// === anyhow integration ===

#[test]
fn config_error_into_anyhow() {
let error = ConfigError::Validation("invalid config".to_string());
let anyhow_err: anyhow::Error = error.into();
assert_eq!(anyhow_err.to_string(), "validation error: invalid config");
}

#[test]
fn preset_error_into_anyhow() {
let error = PresetError::InvalidReference("bad".to_string());
let anyhow_err: anyhow::Error = error.into();
assert_eq!(anyhow_err.to_string(), "invalid reference: bad");
}

#[test]
fn anyhow_error_chain_config_to_preset() {
let preset_err = PresetError::InvalidReference("bad".to_string());
let config_err = ConfigError::Preset(preset_err);
let anyhow_err: anyhow::Error = config_err.into();

let chain: Vec<String> = anyhow_err.chain().map(|e| e.to_string()).collect();
assert_eq!(chain[0], "preset error: invalid reference: bad");
assert_eq!(chain[1], "invalid reference: bad");
}
}
3 changes: 3 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mod error;

pub use error::*;
141 changes: 141 additions & 0 deletions src/exec/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
use std::time::Duration;

#[derive(Debug, thiserror::Error)]
pub enum ExecError {
#[error("command not found: {0}")]
NotFound(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}

#[derive(Debug, thiserror::Error)]
pub enum SandboxError {
#[error("sandbox not supported on this platform")]
NotSupported,
#[error("sandbox setup failed: {0}")]
SetupFailed(String),
#[error("landlock restriction failed: {0}")]
Landlock(String),
#[error("seccomp filter failed: {0}")]
Seccomp(String),
#[error("seatbelt policy failed: {0}")]
Seatbelt(String),
#[error("command execution failed: {0}")]
Exec(#[from] std::io::Error),
}

#[derive(Debug, thiserror::Error)]
pub enum ExtensionError {
#[error("spawn error: {0}")]
Spawn(#[from] std::io::Error),
#[error("timeout after {0:?}")]
Timeout(Duration),
#[error("invalid response: {0}")]
InvalidResponse(String),
}

#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use std::time::Duration;

// === ExecError ===

#[rstest]
#[case(ExecError::NotFound("git".to_string()), "command not found: git")]
#[case(ExecError::PermissionDenied("/usr/bin/secret".to_string()), "permission denied: /usr/bin/secret")]
fn exec_error_display(#[case] error: ExecError, #[case] expected: &str) {
assert_eq!(error.to_string(), expected);
}

#[test]
fn exec_error_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
let exec_err: ExecError = io_err.into();
assert_eq!(exec_err.to_string(), "io error: no such file");
}

#[test]
fn exec_error_io_has_source() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
let exec_err = ExecError::Io(io_err);
let source = std::error::Error::source(&exec_err);
assert!(source.is_some());
}

// === SandboxError ===

#[rstest]
#[case(SandboxError::NotSupported, "sandbox not supported on this platform")]
#[case(SandboxError::SetupFailed("invalid policy".to_string()), "sandbox setup failed: invalid policy")]
#[case(SandboxError::Landlock("ruleset creation failed".to_string()), "landlock restriction failed: ruleset creation failed")]
#[case(SandboxError::Seccomp("filter load failed".to_string()), "seccomp filter failed: filter load failed")]
#[case(SandboxError::Seatbelt("profile compilation failed".to_string()), "seatbelt policy failed: profile compilation failed")]
fn sandbox_error_display(#[case] error: SandboxError, #[case] expected: &str) {
assert_eq!(error.to_string(), expected);
}

#[test]
fn sandbox_error_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
let sandbox_err: SandboxError = io_err.into();
assert_eq!(
sandbox_err.to_string(),
"command execution failed: access denied"
);
}

// === ExtensionError ===

#[test]
fn extension_error_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "plugin not found");
let ext_err: ExtensionError = io_err.into();
assert_eq!(ext_err.to_string(), "spawn error: plugin not found");
}

#[rstest]
#[case(ExtensionError::Timeout(Duration::from_secs(5)), "timeout after 5s")]
#[case(
ExtensionError::InvalidResponse("missing 'result' field".to_string()),
"invalid response: missing 'result' field"
)]
fn extension_error_display(#[case] error: ExtensionError, #[case] expected: &str) {
assert_eq!(error.to_string(), expected);
}

#[test]
fn extension_error_implements_std_error() {
let error: &dyn std::error::Error = &ExtensionError::Timeout(Duration::from_secs(5));
assert!(error.source().is_none());
}

// === anyhow integration ===

#[test]
fn exec_error_into_anyhow() {
let error = ExecError::NotFound("git".to_string());
let anyhow_err: anyhow::Error = error.into();
assert_eq!(anyhow_err.to_string(), "command not found: git");
}

#[test]
fn sandbox_error_into_anyhow() {
let error = SandboxError::NotSupported;
let anyhow_err: anyhow::Error = error.into();
assert_eq!(
anyhow_err.to_string(),
"sandbox not supported on this platform"
);
}

#[test]
fn extension_error_into_anyhow() {
let error = ExtensionError::InvalidResponse("bad json".to_string());
let anyhow_err: anyhow::Error = error.into();
assert_eq!(anyhow_err.to_string(), "invalid response: bad json");
}
}
3 changes: 3 additions & 0 deletions src/exec/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mod error;

pub use error::*;
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod config;
pub mod exec;
pub mod rules;
Loading