1use agent_migrate_core::{
2 AgentKind, Bundle, BundleRelativePath, ChangeKind, Compatibility, FileKind, ImportDecision,
3 ImportPolicy, PathMappingSet, PlannedWrite, Scope, WritePlan,
4};
5
6use crate::error::AdapterError;
7use crate::render_content::{merge_rendered, render_content};
8use crate::report::{ConversionReport, ResourceConversion};
9use crate::target_layout::TargetLayout;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RenderRequest {
13 target: AgentKind,
14 scope: Scope,
15 policy: ImportPolicy,
16 bridge_healthy: bool,
17 path_mappings: Option<PathMappingSet>,
18}
19
20impl RenderRequest {
21 #[must_use]
22 pub const fn new(target: AgentKind, scope: Scope) -> Self {
23 Self {
24 target,
25 scope,
26 policy: ImportPolicy::strict(),
27 bridge_healthy: false,
28 path_mappings: None,
29 }
30 }
31
32 #[must_use]
33 pub const fn accept_lossy(mut self) -> Self {
34 self.policy = ImportPolicy::accept_lossy();
35 self
36 }
37
38 #[must_use]
39 pub const fn with_bridge_healthy(mut self) -> Self {
40 self.bridge_healthy = true;
41 self
42 }
43
44 #[must_use]
45 pub fn with_path_mappings(mut self, path_mappings: PathMappingSet) -> Self {
46 self.path_mappings = Some(path_mappings);
47 self
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct RenderedConfig {
53 pub plan: WritePlan,
54 pub report: ConversionReport,
55}
56
57pub fn plan_conversion(
63 bundle: &Bundle,
64 target: AgentKind,
65 scope: Scope,
66) -> Result<ConversionReport, AdapterError> {
67 let source = bundle.manifest().source.agent;
68 let layout = TargetLayout::new(target, scope);
69 let resources = bundle
70 .manifest()
71 .files
72 .iter()
73 .map(|(path, record)| {
74 let compatibility = compatibility(source, target, record.kind);
75 let target_path = layout.path(record.kind, path)?;
76 Ok(ResourceConversion {
77 source_path: path.clone(),
78 kind: record.kind,
79 compatibility,
80 target_path: Some(target_path),
81 change: ChangeKind::Add,
82 })
83 })
84 .collect::<Result<Vec<_>, AdapterError>>()?;
85 Ok(ConversionReport::new(
86 source,
87 target,
88 resources,
89 bundle.manifest().secret_references.clone(),
90 ))
91}
92
93pub fn render_bundle(
99 bundle: &Bundle,
100 request: &RenderRequest,
101) -> Result<RenderedConfig, AdapterError> {
102 let report = plan_conversion(bundle, request.target, request.scope)?;
103 let mut plan = WritePlan::default();
104 for resource in &report.resources {
105 enforce_policy(resource, request)?;
106 let target_path = resource
107 .target_path
108 .clone()
109 .ok_or(AdapterError::InternalInvariant)?;
110 let content = bundle
111 .files()
112 .get(&resource.source_path)
113 .ok_or(AdapterError::InternalInvariant)?;
114 let mapped = request
115 .path_mappings
116 .as_ref()
117 .map(|mappings| crate::render_content::map_paths(resource.kind, content, mappings))
118 .transpose()?;
119 let content = mapped.as_deref().unwrap_or(content);
120 let rendered =
121 render_content(request.target, resource.kind, content).map_err(|message| {
122 AdapterError::Render {
123 path: resource.source_path.to_string(),
124 message,
125 }
126 })?;
127 let rendered = if let Some(current) = plan.get(&target_path) {
128 merge_rendered(request.target, ¤t.content, &rendered).map_err(|message| {
129 AdapterError::Render {
130 path: resource.source_path.to_string(),
131 message,
132 }
133 })?
134 } else {
135 rendered
136 };
137 let mode = bundle
138 .manifest()
139 .files
140 .get(&resource.source_path)
141 .ok_or(AdapterError::InternalInvariant)?
142 .mode;
143 plan.insert(PlannedWrite::new(target_path, rendered).with_mode(mode));
144 if request.target == AgentKind::Pi && resource.kind == FileKind::Mcp {
145 plan.insert(pi_bridge_write(request.scope)?);
146 }
147 }
148 Ok(RenderedConfig { plan, report })
149}
150
151fn pi_bridge_write(scope: Scope) -> Result<PlannedWrite, AdapterError> {
152 let path = match scope {
153 Scope::Global => ".pi/agent/extensions/agent-migrate-mcp.ts",
154 Scope::Project => ".pi/extensions/agent-migrate-mcp.ts",
155 };
156 let path = path.parse::<BundleRelativePath>()?;
157 let content = include_bytes!("../assets/pi-mcp-bridge.mjs").to_vec();
158 Ok(PlannedWrite::new(path, content))
159}
160
161fn enforce_policy(
162 resource: &ResourceConversion,
163 request: &RenderRequest,
164) -> Result<(), AdapterError> {
165 match request
166 .policy
167 .decide(&resource.compatibility, request.bridge_healthy)
168 {
169 ImportDecision::Allowed => Ok(()),
170 ImportDecision::NeedsLossyAcceptance => Err(AdapterError::LossyAcceptanceRequired {
171 path: resource.source_path.to_string(),
172 }),
173 ImportDecision::BridgeUnavailable => Err(AdapterError::BridgeUnavailable {
174 path: resource.source_path.to_string(),
175 }),
176 ImportDecision::Blocked => Err(AdapterError::CompatibilityBlocked {
177 path: resource.source_path.to_string(),
178 }),
179 }
180}
181
182fn compatibility(source: AgentKind, target: AgentKind, kind: FileKind) -> Compatibility {
183 if source == target {
184 return Compatibility::Exact;
185 }
186 match (target, kind) {
187 (_, FileKind::Skill) => Compatibility::Exact,
188 (AgentKind::Pi, FileKind::Mcp) => Compatibility::Bridged {
189 requirement: "pi-mcp-bridge".to_owned(),
190 },
191 (AgentKind::Pi, FileKind::Agent) => Compatibility::Lossy {
192 reason: "Pi represents agents as callable prompts".to_owned(),
193 },
194 (AgentKind::Pi, FileKind::Hook) => Compatibility::PreservedUnavailable {
195 reason: "Pi hooks remain disabled preserved resources".to_owned(),
196 },
197 (_, FileKind::Passthrough) => Compatibility::PreservedUnavailable {
198 reason: "adapter-specific resource has no guaranteed target behavior".to_owned(),
199 },
200 _ => Compatibility::Converted,
201 }
202}