Skip to main content

agent_migrate_adapters/
conversion.rs

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
57/// Plans target paths and compatibility without writing or rendering files.
58///
59/// # Errors
60///
61/// Returns an error when a Bundle path cannot be mapped to the target layout.
62pub 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
93/// Renders a Bundle into a policy-approved target write plan.
94///
95/// # Errors
96///
97/// Returns an error for blocked, unaccepted lossy, unavailable bridge, or invalid content.
98pub 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, &current.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}