use crate::adapters::{Attestor, LockManager, OwnershipOracle, SmokeTestRunner};
use crate::logging::audit::new_run_id;
use crate::logging::{AuditSink, FactsEmitter, StageLogger};
use crate::policy::Policy;
use crate::types::{ApplyMode, ApplyReport, Plan, PlanInput, PreflightReport};
use serde_json::json;
mod apply;
mod builder;
pub mod errors;
mod overrides;
mod plan;
mod preflight;
mod rollback;
pub use builder::ApiBuilder;
pub use overrides::Overrides;
pub type SwitchyardBuilder<E, A> = ApiBuilder<E, A>;
pub trait DebugLockManager: LockManager + std::fmt::Debug {}
impl<T: LockManager + std::fmt::Debug> DebugLockManager for T {}
pub trait DebugOwnershipOracle: OwnershipOracle + std::fmt::Debug {}
impl<T: OwnershipOracle + std::fmt::Debug> DebugOwnershipOracle for T {}
pub trait DebugAttestor: Attestor + std::fmt::Debug {}
impl<T: Attestor + std::fmt::Debug> DebugAttestor for T {}
pub trait DebugSmokeTestRunner: SmokeTestRunner + std::fmt::Debug {}
impl<T: SmokeTestRunner + std::fmt::Debug> DebugSmokeTestRunner for T {}
#[derive(Debug)]
pub struct Switchyard<E: FactsEmitter, A: AuditSink> {
facts: E,
audit: A,
policy: Policy,
overrides: Overrides,
lock: Option<Box<dyn DebugLockManager>>, owner: Option<Box<dyn DebugOwnershipOracle>>, attest: Option<Box<dyn DebugAttestor>>, smoke: Option<Box<dyn DebugSmokeTestRunner>>, lock_timeout_ms: u64,
}
impl<E: FactsEmitter, A: AuditSink> Switchyard<E, A> {
pub fn new(facts: E, audit: A, policy: Policy) -> Self {
ApiBuilder::new(facts, audit, policy).build()
}
pub fn builder(facts: E, audit: A, policy: Policy) -> ApiBuilder<E, A> {
ApiBuilder::new(facts, audit, policy)
}
#[must_use]
pub fn with_lock_manager(mut self, lock: Box<dyn DebugLockManager>) -> Self {
self.lock = Some(lock);
self
}
#[must_use]
#[allow(
clippy::missing_const_for_fn,
reason = "Not meaningful to expose as const; builder-style setter"
)]
pub fn with_overrides(mut self, overrides: Overrides) -> Self {
self.overrides = overrides;
self
}
#[must_use]
#[allow(
clippy::missing_const_for_fn,
reason = "Getter const provides no benefit; keep simple runtime API"
)]
pub fn overrides(&self) -> &Overrides {
&self.overrides
}
#[must_use]
pub fn with_ownership_oracle(mut self, owner: Box<dyn DebugOwnershipOracle>) -> Self {
self.owner = Some(owner);
self
}
#[must_use]
pub fn with_attestor(mut self, attest: Box<dyn DebugAttestor>) -> Self {
self.attest = Some(attest);
self
}
#[must_use]
pub fn with_smoke_runner(mut self, smoke: Box<dyn DebugSmokeTestRunner>) -> Self {
self.smoke = Some(smoke);
self
}
#[must_use]
pub const fn with_lock_timeout_ms(mut self, timeout_ms: u64) -> Self {
self.lock_timeout_ms = timeout_ms;
self
}
pub fn plan(&self, input: PlanInput) -> Plan {
#[cfg(feature = "tracing")]
let _span = tracing::info_span!("switchyard.plan").entered();
plan::build(self, input)
}
pub fn preflight(&self, plan: &Plan) -> Result<PreflightReport, errors::ApiError> {
#[cfg(feature = "tracing")]
let _span = tracing::info_span!("switchyard.preflight").entered();
Ok(preflight::run(self, plan))
}
pub fn apply(&self, plan: &Plan, mode: ApplyMode) -> Result<ApplyReport, errors::ApiError> {
#[cfg(feature = "tracing")]
let _span = tracing::info_span!("switchyard.apply", mode = ?mode).entered();
let report = apply::run(self, plan, mode);
if matches!(mode, ApplyMode::Commit) && !report.errors.is_empty() {
let joined = report.errors.join("; ").to_lowercase();
if joined.contains("lock") {
return Err(errors::ApiError::LockingTimeout(
"lock manager required or acquisition failed".to_string(),
));
}
}
Ok(report)
}
pub fn plan_rollback_of(&self, report: &ApplyReport) -> Plan {
#[cfg(feature = "tracing")]
let _span = tracing::info_span!("switchyard.plan_rollback").entered();
let plan_like = format!(
"rollback:{}",
report
.plan_uuid
.map_or_else(|| "unknown".to_string(), |u| u.to_string())
);
let pid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, plan_like.as_bytes());
let run_id = new_run_id();
let tctx = crate::logging::audit::AuditCtx::new(
&self.facts,
pid.to_string(),
run_id,
crate::logging::redact::now_iso(),
crate::logging::audit::AuditMode {
dry_run: false,
redact: false,
},
);
StageLogger::new(&tctx)
.rollback()
.merge(&json!({
"planning": true,
"executed": report.executed.len(),
}))
.emit_success();
rollback::inverse_with_policy(&self.policy, report)
}
pub fn prune_backups(
&self,
target: &crate::types::safepath::SafePath,
) -> Result<crate::types::PruneResult, errors::ApiError> {
#[cfg(feature = "tracing")]
let _span = tracing::info_span!(
"switchyard.prune_backups",
path = %target.as_path().display(),
tag = %self.policy.backup.tag
)
.entered();
let plan_like = format!(
"prune:{}:{}",
target.as_path().display(),
self.policy.backup.tag
);
let pid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, plan_like.as_bytes());
let run_id = new_run_id();
let tctx = crate::logging::audit::AuditCtx::new(
&self.facts,
pid.to_string(),
run_id,
crate::logging::redact::now_iso(),
crate::logging::audit::AuditMode {
dry_run: false,
redact: false,
},
);
let count_limit = self.policy.retention_count_limit;
let age_limit = self.policy.retention_age_limit;
match crate::fs::backup::prune::prune_backups(
target,
&self.policy.backup.tag,
count_limit,
age_limit,
) {
Ok(res) => {
StageLogger::new(&tctx).prune_result().merge(&json!({
"path": target.as_path().display().to_string(),
"backup_tag": self.policy.backup.tag,
"retention_count_limit": count_limit,
"retention_age_limit_ms": age_limit.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
"pruned_count": res.pruned_count,
"retained_count": res.retained_count,
})).emit_success();
Ok(res)
}
Err(e) => {
StageLogger::new(&tctx)
.prune_result()
.merge(&json!({
"path": target.as_path().display().to_string(),
"backup_tag": self.policy.backup.tag,
"error": e.to_string(),
"error_id": errors::id_str(errors::ErrorId::E_GENERIC),
"exit_code": errors::exit_code_for(errors::ErrorId::E_GENERIC),
}))
.emit_failure();
Err(errors::ApiError::FilesystemError(e.to_string()))
}
}
}
}