use crate::telemetry::Telemetry;
use crate::Result;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
#[allow(clippy::exhaustive_enums)] pub enum CapabilityError {
#[error("invalid arguments: {0}")]
InvalidArgs(String),
#[error("blocked: {0}")]
PermissionDenied(String),
#[error("file not found: {0}")]
NotFound(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("git error: {0}")]
Git(String),
#[error("internal error: {0}")]
Internal(String),
}
#[allow(clippy::exhaustive_structs)] pub struct Context {
pub dry_run: bool,
pub job_id: String,
pub working_dir: PathBuf,
}
impl Context {
#[must_use]
pub fn new(dry_run: bool, job_id: String) -> Self {
Self {
dry_run,
job_id,
working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
}
}
#[must_use]
pub fn with_working_dir(dry_run: bool, job_id: String, working_dir: PathBuf) -> Self {
Self {
dry_run,
job_id,
working_dir,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[allow(clippy::exhaustive_structs)] pub struct Output {
pub status: String,
pub output: String,
pub data: Option<Value>,
pub backup_path: Option<PathBuf>,
pub error: Option<String>,
pub duration_ms: u64,
pub telemetry_delta: Telemetry,
pub artifacts: Vec<PathBuf>,
}
impl Output {
#[must_use]
pub fn ok(output: String) -> Self {
Self {
status: "ok".to_string(),
output,
data: None,
backup_path: None,
error: None,
duration_ms: 0,
telemetry_delta: Telemetry::capture_lightweight(),
artifacts: Vec::new(),
}
}
#[must_use]
pub fn error(output: String, error: String) -> Self {
Self {
status: "error".to_string(),
output,
data: None,
backup_path: None,
error: Some(error),
duration_ms: 0,
telemetry_delta: Telemetry::capture_lightweight(),
artifacts: Vec::new(),
}
}
#[must_use]
pub fn to_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
pub trait Capability: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn schema(&self) -> Value;
fn validate(&self, args: &Value) -> Result<()>;
fn execute(&self, args: &Value, ctx: &Context) -> Result<Output>;
}
pub trait TypedCapability: Send + Sync {
type Args: DeserializeOwned + Send + Sync;
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn schema(&self) -> Value;
fn execute(
&self,
args: Self::Args,
ctx: &Context,
) -> std::result::Result<Output, CapabilityError>;
fn dry_run(
&self,
args: Self::Args,
ctx: &Context,
) -> std::result::Result<Output, CapabilityError> {
self.execute(args, ctx)
}
}
impl<T: TypedCapability> Capability for T {
fn name(&self) -> &'static str {
TypedCapability::name(self)
}
fn description(&self) -> &'static str {
TypedCapability::description(self)
}
fn schema(&self) -> Value {
TypedCapability::schema(self)
}
fn validate(&self, _args: &Value) -> Result<()> {
Ok(())
}
fn execute(&self, args: &Value, ctx: &Context) -> Result<Output> {
let typed_args: T::Args = serde_json::from_value(args.clone())
.map_err(|e| crate::Error::SchemaValidationFailed(e.to_string()))?;
TypedCapability::execute(self, typed_args, ctx)
.map_err(|e| crate::Error::ExecutionFailed(e.to_string()))
}
}
pub struct CapabilityRegistry {
capabilities: std::collections::HashMap<String, Box<dyn Capability>>,
}
impl CapabilityRegistry {
#[must_use]
pub fn new() -> Self {
Self {
capabilities: std::collections::HashMap::new(),
}
}
pub fn register<C: Capability + 'static>(&mut self, capability: C) {
let name = capability.name().to_string();
self.capabilities.insert(name, Box::new(capability));
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&dyn Capability> {
if let Some(cap) = self.capabilities.get(name) {
return Some(cap.as_ref());
}
let name_lower = name.to_lowercase();
for (key, cap) in &self.capabilities {
if key.to_lowercase() == name_lower {
return Some(cap.as_ref());
}
}
None
}
#[must_use]
pub fn list(&self) -> Vec<&str> {
self.capabilities.keys().map(|s| s.as_str()).collect()
}
}
impl Default for CapabilityRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::Value;
struct TestCap {
name: &'static str,
}
impl Capability for TestCap {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"test capability"
}
fn schema(&self) -> Value {
serde_json::json!({})
}
fn validate(&self, _args: &Value) -> crate::Result<()> {
Ok(())
}
fn execute(&self, _args: &Value, _ctx: &Context) -> crate::Result<Output> {
Ok(Output::ok("test completed".into()))
}
}
#[test]
fn test_registry_register_and_get() {
let mut reg = CapabilityRegistry::new();
reg.register(TestCap { name: "Alpha" });
let cap = reg.get("Alpha");
assert!(cap.is_some(), "Should find registered capability");
assert_eq!(cap.unwrap().name(), "Alpha");
}
#[test]
fn test_registry_duplicate_name_replaces() {
let mut reg = CapabilityRegistry::new();
reg.register(TestCap { name: "Beta" });
reg.register(TestCap { name: "Beta" });
let cap = reg.get("Beta");
assert!(
cap.is_some(),
"Should still find capability after duplicate registration"
);
assert_eq!(cap.unwrap().name(), "Beta");
}
#[test]
fn test_registry_case_insensitive_lookup() {
let mut reg = CapabilityRegistry::new();
reg.register(TestCap { name: "ShellExec" });
assert!(reg.get("ShellExec").is_some());
assert!(
reg.get("shellexec").is_some(),
"Case-insensitive lookup should find ShellExec"
);
assert!(
reg.get("SHELLEXEC").is_some(),
"Uppercase lookup should find ShellExec"
);
assert!(
reg.get("ShellExec").is_some(),
"Exact-case lookup should find ShellExec"
);
}
#[test]
fn test_registry_unregistered_lookup_returns_none() {
let mut reg = CapabilityRegistry::new();
reg.register(TestCap { name: "Delta" });
assert!(reg.get("NoSuchCap").is_none());
assert!(reg.get("").is_none());
assert!(reg.get("gamma").is_none());
}
#[test]
fn test_registry_list() {
let mut reg = CapabilityRegistry::new();
assert!(reg.list().is_empty());
reg.register(TestCap { name: "A" });
reg.register(TestCap { name: "B" });
reg.register(TestCap { name: "C" });
let list = reg.list();
assert_eq!(list.len(), 3);
assert!(list.contains(&"A"));
assert!(list.contains(&"B"));
assert!(list.contains(&"C"));
}
#[test]
fn test_capability_error_display() {
let err = CapabilityError::InvalidArgs("missing field `path`".into());
assert!(err.to_string().contains("invalid arguments"));
let err = CapabilityError::PermissionDenied("/etc/shadow".into());
assert!(err.to_string().contains("blocked"));
let err = CapabilityError::NotFound("/tmp/nonexistent.txt".into());
assert!(err.to_string().contains("file not found"));
let err = CapabilityError::Git("fatal: not a repository".into());
assert!(err.to_string().contains("git error"));
let err = CapabilityError::Internal("unexpected state".into());
assert!(err.to_string().contains("internal error"));
}
#[test]
fn test_capability_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
let cap_err: CapabilityError = io_err.into();
assert!(matches!(cap_err, CapabilityError::Io(_)));
assert!(cap_err.to_string().contains("io error"));
}
#[test]
fn test_capability_error_debug_format() {
let err = CapabilityError::InvalidArgs("test".into());
let debug = format!("{:?}", err);
assert!(debug.contains("InvalidArgs"));
}
#[test]
fn test_output_ok_constructor() {
let out = Output::ok("done".into());
assert_eq!(out.status, "ok");
assert_eq!(out.output, "done");
assert!(out.error.is_none());
assert!(out.data.is_none());
assert!(out.backup_path.is_none());
assert!(out.artifacts.is_empty());
}
#[test]
fn test_output_error_constructor() {
let out = Output::error("failed".into(), "not found".into());
assert_eq!(out.status, "error");
assert_eq!(out.output, "failed");
assert_eq!(out.error.as_deref(), Some("not found"));
}
#[test]
fn test_output_to_json() {
let out = Output::ok("test".into());
let json = out.to_json();
assert_eq!(json["status"], "ok");
assert_eq!(json["output"], "test");
assert!(json["error"].is_null());
}
struct TypedTestCap;
impl TypedCapability for TypedTestCap {
type Args = serde_json::Value;
fn name(&self) -> &'static str {
"TypedTest"
}
fn description(&self) -> &'static str {
"typed test capability"
}
fn schema(&self) -> Value {
serde_json::json!({"type": "object"})
}
fn execute(
&self,
args: Self::Args,
_ctx: &Context,
) -> std::result::Result<Output, CapabilityError> {
Ok(Output::ok(format!("typed: {}", args)))
}
}
#[test]
fn test_typed_capability_blanket_impl_bridge() {
let mut reg = CapabilityRegistry::new();
reg.register(TypedTestCap);
let cap = reg.get("TypedTest").unwrap();
assert_eq!(cap.name(), "TypedTest");
let result = cap.execute(
&serde_json::json!("hello"),
&Context::new(false, "test".into()),
);
assert!(result.is_ok());
let output = result.unwrap();
assert_eq!(output.status, "ok");
}
#[test]
fn test_typed_capability_validate_always_ok() {
let cap = TypedTestCap;
let result = Capability::validate(&cap, &serde_json::json!({}));
assert!(result.is_ok());
}
}