use agent_migrate_core::{
AgentKind, Bundle, BundleRelativePath, ChangeKind, Compatibility, FileKind, ImportDecision,
ImportPolicy, PathMappingSet, PlannedWrite, Scope, WritePlan,
};
use crate::error::AdapterError;
use crate::render_content::{merge_rendered, render_content};
use crate::report::{ConversionReport, ResourceConversion};
use crate::target_layout::TargetLayout;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderRequest {
target: AgentKind,
scope: Scope,
policy: ImportPolicy,
bridge_healthy: bool,
path_mappings: Option<PathMappingSet>,
}
impl RenderRequest {
#[must_use]
pub const fn new(target: AgentKind, scope: Scope) -> Self {
Self {
target,
scope,
policy: ImportPolicy::strict(),
bridge_healthy: false,
path_mappings: None,
}
}
#[must_use]
pub const fn accept_lossy(mut self) -> Self {
self.policy = ImportPolicy::accept_lossy();
self
}
#[must_use]
pub const fn with_bridge_healthy(mut self) -> Self {
self.bridge_healthy = true;
self
}
#[must_use]
pub fn with_path_mappings(mut self, path_mappings: PathMappingSet) -> Self {
self.path_mappings = Some(path_mappings);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedConfig {
pub plan: WritePlan,
pub report: ConversionReport,
}
pub fn plan_conversion(
bundle: &Bundle,
target: AgentKind,
scope: Scope,
) -> Result<ConversionReport, AdapterError> {
let source = bundle.manifest().source.agent;
let layout = TargetLayout::new(target, scope);
let resources = bundle
.manifest()
.files
.iter()
.map(|(path, record)| {
let compatibility = compatibility(source, target, record.kind);
let target_path = layout.path(record.kind, path)?;
Ok(ResourceConversion {
source_path: path.clone(),
kind: record.kind,
compatibility,
target_path: Some(target_path),
change: ChangeKind::Add,
})
})
.collect::<Result<Vec<_>, AdapterError>>()?;
Ok(ConversionReport::new(
source,
target,
resources,
bundle.manifest().secret_references.clone(),
))
}
pub fn render_bundle(
bundle: &Bundle,
request: &RenderRequest,
) -> Result<RenderedConfig, AdapterError> {
let report = plan_conversion(bundle, request.target, request.scope)?;
let mut plan = WritePlan::default();
for resource in &report.resources {
enforce_policy(resource, request)?;
let target_path = resource
.target_path
.clone()
.ok_or(AdapterError::InternalInvariant)?;
let content = bundle
.files()
.get(&resource.source_path)
.ok_or(AdapterError::InternalInvariant)?;
let mapped = request
.path_mappings
.as_ref()
.map(|mappings| crate::render_content::map_paths(resource.kind, content, mappings))
.transpose()?;
let content = mapped.as_deref().unwrap_or(content);
let rendered =
render_content(request.target, resource.kind, content).map_err(|message| {
AdapterError::Render {
path: resource.source_path.to_string(),
message,
}
})?;
let rendered = if let Some(current) = plan.get(&target_path) {
merge_rendered(request.target, ¤t.content, &rendered).map_err(|message| {
AdapterError::Render {
path: resource.source_path.to_string(),
message,
}
})?
} else {
rendered
};
let mode = bundle
.manifest()
.files
.get(&resource.source_path)
.ok_or(AdapterError::InternalInvariant)?
.mode;
plan.insert(PlannedWrite::new(target_path, rendered).with_mode(mode));
if request.target == AgentKind::Pi && resource.kind == FileKind::Mcp {
plan.insert(pi_bridge_write(request.scope)?);
}
}
Ok(RenderedConfig { plan, report })
}
fn pi_bridge_write(scope: Scope) -> Result<PlannedWrite, AdapterError> {
let path = match scope {
Scope::Global => ".pi/agent/extensions/agent-migrate-mcp.ts",
Scope::Project => ".pi/extensions/agent-migrate-mcp.ts",
};
let path = path.parse::<BundleRelativePath>()?;
let content = include_bytes!("../assets/pi-mcp-bridge.mjs").to_vec();
Ok(PlannedWrite::new(path, content))
}
fn enforce_policy(
resource: &ResourceConversion,
request: &RenderRequest,
) -> Result<(), AdapterError> {
match request
.policy
.decide(&resource.compatibility, request.bridge_healthy)
{
ImportDecision::Allowed => Ok(()),
ImportDecision::NeedsLossyAcceptance => Err(AdapterError::LossyAcceptanceRequired {
path: resource.source_path.to_string(),
}),
ImportDecision::BridgeUnavailable => Err(AdapterError::BridgeUnavailable {
path: resource.source_path.to_string(),
}),
ImportDecision::Blocked => Err(AdapterError::CompatibilityBlocked {
path: resource.source_path.to_string(),
}),
}
}
fn compatibility(source: AgentKind, target: AgentKind, kind: FileKind) -> Compatibility {
if source == target {
return Compatibility::Exact;
}
match (target, kind) {
(_, FileKind::Skill) => Compatibility::Exact,
(AgentKind::Pi, FileKind::Mcp) => Compatibility::Bridged {
requirement: "pi-mcp-bridge".to_owned(),
},
(AgentKind::Pi, FileKind::Agent) => Compatibility::Lossy {
reason: "Pi represents agents as callable prompts".to_owned(),
},
(AgentKind::Pi, FileKind::Hook) => Compatibility::PreservedUnavailable {
reason: "Pi hooks remain disabled preserved resources".to_owned(),
},
(_, FileKind::Passthrough) => Compatibility::PreservedUnavailable {
reason: "adapter-specific resource has no guaranteed target behavior".to_owned(),
},
_ => Compatibility::Converted,
}
}