use std::path::PathBuf;
use crate::error::AgentConfigError;
use crate::plan::{InstallPlan, PlanTarget, UninstallPlan};
use crate::scope::{Scope, ScopeKind};
use crate::spec::{HookSpec, InstructionSpec, McpSpec, SkillSpec};
use crate::status::{InstallStatus, StatusReport};
use crate::validation::ValidationReport;
pub trait Integration: Send + Sync {
fn id(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn supported_scopes(&self) -> &'static [ScopeKind];
fn is_installed(&self, scope: &Scope, tag: &str) -> Result<bool, AgentConfigError> {
Ok(matches!(
self.status(scope, tag)?.status,
InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
))
}
fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError>;
fn validate(&self, scope: &Scope, tag: &str) -> Result<ValidationReport, AgentConfigError> {
HookSpec::validate_tag(tag)?;
let target = PlanTarget::Hook {
integration_id: self.id(),
scope: scope.clone(),
tag: tag.to_string(),
};
let status = match self.status(scope, tag) {
Ok(status) => status,
Err(AgentConfigError::JsonInvalid { path, source }) => {
return Ok(crate::validation::malformed_ledger_report(
target,
path,
source.to_string(),
));
}
Err(e) => return Err(e),
};
Ok(crate::validation::hook_report_from_status(target, status))
}
fn plan_install(&self, scope: &Scope, spec: &HookSpec)
-> Result<InstallPlan, AgentConfigError>;
fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError>;
fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError>;
fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError>;
fn migrate(&self, _scope: &Scope, _tag: &str) -> Result<MigrationReport, AgentConfigError> {
Ok(MigrationReport::NoOp)
}
}
pub trait McpSurface: Send + Sync {
fn id(&self) -> &'static str;
fn supported_mcp_scopes(&self) -> &'static [ScopeKind];
fn is_mcp_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
Ok(matches!(
self.mcp_status(scope, name, self.id())?.status,
InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
))
}
fn mcp_status(
&self,
scope: &Scope,
name: &str,
expected_owner: &str,
) -> Result<StatusReport, AgentConfigError>;
fn validate_mcp(
&self,
scope: &Scope,
name: &str,
) -> Result<ValidationReport, AgentConfigError> {
self.validate_mcp_for_owner(scope, name, None)
}
fn validate_mcp_for_owner(
&self,
scope: &Scope,
name: &str,
expected_owner: Option<&str>,
) -> Result<ValidationReport, AgentConfigError> {
McpSpec::validate_name(name)?;
if let Some(owner) = expected_owner {
HookSpec::validate_tag(owner)?;
}
let status = match self.mcp_status(scope, name, expected_owner.unwrap_or("")) {
Ok(status) => status,
Err(AgentConfigError::JsonInvalid { path, source }) => {
let target = PlanTarget::Mcp {
integration_id: self.id(),
scope: scope.clone(),
name: name.to_string(),
owner: expected_owner.unwrap_or_default().to_string(),
};
return Ok(crate::validation::malformed_ledger_report(
target,
path,
source.to_string(),
));
}
Err(e) => return Err(e),
};
let target = PlanTarget::Mcp {
integration_id: self.id(),
scope: scope.clone(),
name: name.to_string(),
owner: expected_owner
.map(str::to_owned)
.or_else(|| owner_from_status(&status))
.unwrap_or_default(),
};
crate::validation::ledger_backed_report_from_status(target, name, expected_owner, status)
}
fn plan_install_mcp(
&self,
scope: &Scope,
spec: &McpSpec,
) -> Result<InstallPlan, AgentConfigError>;
fn plan_uninstall_mcp(
&self,
scope: &Scope,
name: &str,
owner_tag: &str,
) -> Result<UninstallPlan, AgentConfigError>;
fn install_mcp(&self, scope: &Scope, spec: &McpSpec)
-> Result<InstallReport, AgentConfigError>;
fn uninstall_mcp(
&self,
scope: &Scope,
name: &str,
owner_tag: &str,
) -> Result<UninstallReport, AgentConfigError>;
}
pub trait SkillSurface: Send + Sync {
fn id(&self) -> &'static str;
fn supported_skill_scopes(&self) -> &'static [ScopeKind];
fn is_skill_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
Ok(matches!(
self.skill_status(scope, name, self.id())?.status,
InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
))
}
fn skill_status(
&self,
scope: &Scope,
name: &str,
expected_owner: &str,
) -> Result<StatusReport, AgentConfigError>;
fn validate_skill(
&self,
scope: &Scope,
name: &str,
) -> Result<ValidationReport, AgentConfigError> {
self.validate_skill_for_owner(scope, name, None)
}
fn validate_skill_for_owner(
&self,
scope: &Scope,
name: &str,
expected_owner: Option<&str>,
) -> Result<ValidationReport, AgentConfigError> {
SkillSpec::validate_name(name)?;
if let Some(owner) = expected_owner {
HookSpec::validate_tag(owner)?;
}
let status = match self.skill_status(scope, name, expected_owner.unwrap_or("")) {
Ok(status) => status,
Err(AgentConfigError::JsonInvalid { path, source }) => {
let target = PlanTarget::Skill {
integration_id: self.id(),
scope: scope.clone(),
name: name.to_string(),
owner: expected_owner.unwrap_or_default().to_string(),
};
return Ok(crate::validation::malformed_ledger_report(
target,
path,
source.to_string(),
));
}
Err(e) => return Err(e),
};
let target = PlanTarget::Skill {
integration_id: self.id(),
scope: scope.clone(),
name: name.to_string(),
owner: expected_owner
.map(str::to_owned)
.or_else(|| owner_from_status(&status))
.unwrap_or_default(),
};
crate::validation::skill_report_from_status(target, name, expected_owner, status)
}
fn plan_install_skill(
&self,
scope: &Scope,
spec: &SkillSpec,
) -> Result<InstallPlan, AgentConfigError>;
fn plan_uninstall_skill(
&self,
scope: &Scope,
name: &str,
owner_tag: &str,
) -> Result<UninstallPlan, AgentConfigError>;
fn install_skill(
&self,
scope: &Scope,
spec: &SkillSpec,
) -> Result<InstallReport, AgentConfigError>;
fn uninstall_skill(
&self,
scope: &Scope,
name: &str,
owner_tag: &str,
) -> Result<UninstallReport, AgentConfigError>;
}
pub trait InstructionSurface: Send + Sync {
fn id(&self) -> &'static str;
fn supported_instruction_scopes(&self) -> &'static [ScopeKind];
fn is_instruction_installed(
&self,
scope: &Scope,
name: &str,
) -> Result<bool, AgentConfigError> {
Ok(matches!(
self.instruction_status(scope, name, self.id())?.status,
InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
))
}
fn instruction_status(
&self,
scope: &Scope,
name: &str,
expected_owner: &str,
) -> Result<StatusReport, AgentConfigError>;
fn validate_instruction(
&self,
scope: &Scope,
name: &str,
) -> Result<ValidationReport, AgentConfigError> {
self.validate_instruction_for_owner(scope, name, None)
}
fn validate_instruction_for_owner(
&self,
scope: &Scope,
name: &str,
expected_owner: Option<&str>,
) -> Result<ValidationReport, AgentConfigError> {
InstructionSpec::validate_name(name)?;
if let Some(owner) = expected_owner {
HookSpec::validate_tag(owner)?;
}
let status = match self.instruction_status(scope, name, expected_owner.unwrap_or("")) {
Ok(status) => status,
Err(AgentConfigError::JsonInvalid { path, source }) => {
let target = PlanTarget::Instruction {
integration_id: self.id(),
scope: scope.clone(),
name: name.to_string(),
owner: expected_owner.unwrap_or_default().to_string(),
};
return Ok(crate::validation::malformed_ledger_report(
target,
path,
source.to_string(),
));
}
Err(e) => return Err(e),
};
let target = PlanTarget::Instruction {
integration_id: self.id(),
scope: scope.clone(),
name: name.to_string(),
owner: expected_owner
.map(str::to_owned)
.or_else(|| owner_from_status(&status))
.unwrap_or_default(),
};
crate::validation::ledger_backed_report_from_status(target, name, expected_owner, status)
}
fn plan_install_instruction(
&self,
scope: &Scope,
spec: &InstructionSpec,
) -> Result<InstallPlan, AgentConfigError>;
fn plan_uninstall_instruction(
&self,
scope: &Scope,
name: &str,
owner_tag: &str,
) -> Result<UninstallPlan, AgentConfigError>;
fn install_instruction(
&self,
scope: &Scope,
spec: &InstructionSpec,
) -> Result<InstallReport, AgentConfigError>;
fn uninstall_instruction(
&self,
scope: &Scope,
name: &str,
owner_tag: &str,
) -> Result<UninstallReport, AgentConfigError>;
}
#[must_use]
#[derive(Debug, Default, Clone)]
#[non_exhaustive]
pub struct InstallReport {
pub created: Vec<PathBuf>,
pub patched: Vec<PathBuf>,
pub backed_up: Vec<PathBuf>,
pub already_installed: bool,
}
impl InstallReport {
pub(crate) fn merge(&mut self, from: InstallReport) {
if !from.already_installed {
self.already_installed = false;
} else if self.created.is_empty() && self.patched.is_empty() {
self.already_installed = true;
}
self.created.extend(from.created);
self.patched.extend(from.patched);
self.backed_up.extend(from.backed_up);
}
}
#[must_use]
#[derive(Debug, Default, Clone)]
#[non_exhaustive]
pub struct UninstallReport {
pub removed: Vec<PathBuf>,
pub patched: Vec<PathBuf>,
pub restored: Vec<PathBuf>,
pub not_installed: bool,
}
impl UninstallReport {
pub(crate) fn merge(&mut self, from: UninstallReport) {
self.not_installed = from.not_installed
&& self.removed.is_empty()
&& self.patched.is_empty()
&& self.restored.is_empty();
self.removed.extend(from.removed);
self.patched.extend(from.patched);
self.restored.extend(from.restored);
}
}
#[must_use]
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MigrationReport {
NoOp,
Migrated {
removed: Vec<PathBuf>,
rewritten: Vec<PathBuf>,
},
}
fn owner_from_status(status: &StatusReport) -> Option<String> {
match &status.status {
InstallStatus::InstalledOwned { owner }
| InstallStatus::InstalledOtherOwner { owner }
| InstallStatus::LedgerOnly { owner } => Some(owner.clone()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_report_default() {
let r = InstallReport::default();
assert!(r.created.is_empty());
assert!(r.patched.is_empty());
assert!(r.backed_up.is_empty());
assert!(!r.already_installed);
}
#[test]
fn uninstall_report_default() {
let r = UninstallReport::default();
assert!(r.removed.is_empty());
assert!(r.patched.is_empty());
assert!(r.restored.is_empty());
assert!(!r.not_installed);
}
#[test]
fn migration_report_debug_clone() {
let noop = MigrationReport::NoOp;
let _ = format!("{noop:?}");
let migrated = MigrationReport::Migrated {
removed: vec![PathBuf::from("/a")],
rewritten: vec![],
};
let cloned = migrated.clone();
let _ = format!("{cloned:?}");
}
}