Skip to main content

agent_migrate_adapters/
adapter.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use agent_migrate_core::{
6    AgentKind, Bundle, BundleRelativePath, FileKind, Manifest, Scope, SourceDescriptor,
7    SourceEnvironment,
8};
9use walkdir::WalkDir;
10
11use crate::context::{ExportContext, ScanContext};
12use crate::error::AdapterError;
13use crate::inventory::{Inventory, MigratableLocation, ResourceClass, ResourceEntry};
14use crate::layout::{Candidate, candidates};
15use crate::sanitize::sanitize;
16
17pub trait Adapter {
18    fn kind(&self) -> AgentKind;
19    /// Discovers resources within the declared global or project boundary.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error for missing workspace, filesystem failure, or path escape.
24    fn scan(&self, context: &ScanContext) -> Result<Inventory, AdapterError>;
25    /// Exports a verified and sanitized canonical Bundle.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error when discovery, parsing, redaction, or Bundle verification fails.
30    fn export(&self, context: &ExportContext) -> Result<Bundle, AdapterError>;
31}
32
33#[derive(Debug, Clone, Copy)]
34pub struct AgentAdapter {
35    kind: AgentKind,
36}
37
38#[must_use]
39pub const fn adapter_for(kind: AgentKind) -> AgentAdapter {
40    AgentAdapter { kind }
41}
42
43impl Adapter for AgentAdapter {
44    fn kind(&self) -> AgentKind {
45        self.kind
46    }
47
48    fn scan(&self, context: &ScanContext) -> Result<Inventory, AdapterError> {
49        let root = scan_root(context)?;
50        let canonical_root = root.canonicalize().map_err(|source| AdapterError::Io {
51            path: root.clone(),
52            source,
53        })?;
54        let mut scanner = Scanner::new(self.kind, root, canonical_root);
55        for candidate in candidates(self.kind, context.scope()) {
56            scanner.collect(*candidate)?;
57        }
58        Ok(Inventory::new(self.kind, context.scope(), scanner.entries))
59    }
60
61    fn export(&self, context: &ExportContext) -> Result<Bundle, AdapterError> {
62        let inventory = self.scan(context.scan())?;
63        let mut files = BTreeMap::new();
64        let mut secret_references = Vec::new();
65        for entry in inventory
66            .entries
67            .iter()
68            .filter(|entry| entry.class == ResourceClass::Migratable)
69        {
70            let source_path = entry.source_path().ok_or(AdapterError::InternalInvariant)?;
71            let bundle_path = entry
72                .bundle_path
73                .clone()
74                .ok_or(AdapterError::InternalInvariant)?;
75            let kind = entry.kind.ok_or(AdapterError::InternalInvariant)?;
76            let bytes = fs::read(source_path).map_err(|source| AdapterError::Io {
77                path: source_path.to_path_buf(),
78                source,
79            })?;
80            let mode = portable_mode(source_path)?;
81            let mut sanitized = sanitize(source_path, kind, &bytes)?;
82            for reference in &mut sanitized.references {
83                reference.logical_location = format!(
84                    "{}{}",
85                    bundle_path,
86                    reference.logical_location.trim_start_matches('$')
87                );
88            }
89            secret_references.extend(sanitized.references);
90            if let Some(mcp) = sanitized.mcp {
91                files.insert(mcp_path(&bundle_path)?, (FileKind::Mcp, 0o600, mcp));
92            }
93            files.insert(bundle_path, (kind, mode, sanitized.content));
94        }
95        let environment = SourceEnvironment::new(
96            context.agent_version(),
97            context.scan().scope(),
98            format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
99        );
100        let source = SourceDescriptor::new(self.kind, environment);
101        let manifest =
102            Manifest::new(context.identity(), source).with_secret_references(secret_references);
103        Bundle::from_files_with_modes(manifest, files).map_err(Into::into)
104    }
105}
106
107#[cfg(unix)]
108fn portable_mode(path: &Path) -> Result<u32, AdapterError> {
109    use std::os::unix::fs::PermissionsExt;
110
111    fs::metadata(path)
112        .map(|metadata| metadata.permissions().mode() & 0o777)
113        .map_err(|source| AdapterError::Io {
114            path: path.to_path_buf(),
115            source,
116        })
117}
118
119#[cfg(not(unix))]
120fn portable_mode(_path: &Path) -> Result<u32, AdapterError> {
121    Ok(0o600)
122}
123
124fn mcp_path(settings: &BundleRelativePath) -> Result<BundleRelativePath, AdapterError> {
125    let value = settings.to_string();
126    let suffix = value
127        .strip_prefix("settings/")
128        .ok_or(AdapterError::InternalInvariant)?;
129    format!("mcp/{suffix}").parse().map_err(Into::into)
130}
131
132#[derive(Debug)]
133struct Scanner {
134    agent: AgentKind,
135    root: PathBuf,
136    canonical_root: PathBuf,
137    entries: Vec<ResourceEntry>,
138}
139
140impl Scanner {
141    fn new(agent: AgentKind, root: PathBuf, canonical_root: PathBuf) -> Self {
142        Self {
143            agent,
144            root,
145            canonical_root,
146            entries: Vec::new(),
147        }
148    }
149
150    fn collect(&mut self, candidate: Candidate) -> Result<(), AdapterError> {
151        let path = self.root.join(candidate.path);
152        if !path.exists() && !path.is_symlink() {
153            return Ok(());
154        }
155        match candidate.class {
156            ResourceClass::Migratable if candidate.tree => self.collect_tree(candidate, &path),
157            ResourceClass::Migratable => self.collect_file(candidate, path),
158            ResourceClass::MetadataOnly | ResourceClass::Excluded => {
159                let index = self.entries.len();
160                self.entries.push(ResourceEntry::protected(
161                    candidate.class,
162                    index,
163                    protected_reason(candidate.class),
164                ));
165                Ok(())
166            }
167        }
168    }
169
170    fn collect_tree(&mut self, candidate: Candidate, root: &Path) -> Result<(), AdapterError> {
171        for item in WalkDir::new(root).follow_links(false).min_depth(1) {
172            let entry = item.map_err(|error| AdapterError::Walk(error.to_string()))?;
173            if entry.file_type().is_dir() {
174                continue;
175            }
176            self.collect_file(candidate, entry.path().to_path_buf())?;
177        }
178        Ok(())
179    }
180
181    fn collect_file(&mut self, candidate: Candidate, path: PathBuf) -> Result<(), AdapterError> {
182        self.ensure_contained(&path)?;
183        let logical = path
184            .strip_prefix(&self.root)
185            .map_err(|_| AdapterError::InternalInvariant)?;
186        let kind = candidate.kind.ok_or(AdapterError::InternalInvariant)?;
187        let bundle_path = self.bundle_path(candidate, logical)?;
188        self.entries.push(ResourceEntry::migratable(
189            MigratableLocation {
190                logical: logical.to_string_lossy().into_owned(),
191                source: path,
192                bundle: bundle_path,
193            },
194            kind,
195        ));
196        Ok(())
197    }
198
199    fn bundle_path(
200        &self,
201        candidate: Candidate,
202        logical: &Path,
203    ) -> Result<BundleRelativePath, AdapterError> {
204        let source_root = Path::new(candidate.path);
205        let suffix = logical.strip_prefix(source_root).unwrap_or(logical);
206        let suffix = if suffix.as_os_str().is_empty() {
207            logical
208                .file_name()
209                .map(Path::new)
210                .ok_or(AdapterError::InternalInvariant)?
211        } else {
212            suffix
213        };
214        let kind = candidate.kind.ok_or(AdapterError::InternalInvariant)?;
215        let category = match kind {
216            FileKind::Skill => "skills",
217            FileKind::Mcp => "mcp",
218            FileKind::Instructions => "instructions",
219            FileKind::Command => "commands",
220            FileKind::Agent => "agents",
221            FileKind::Hook => "hooks",
222            FileKind::Settings => "settings",
223            FileKind::Passthrough => "passthrough",
224        };
225        let path = format!("{category}/{}/{}", self.agent, suffix.to_string_lossy());
226        path.parse().map_err(Into::into)
227    }
228
229    fn ensure_contained(&self, path: &Path) -> Result<(), AdapterError> {
230        let canonical = path.canonicalize().map_err(|source| AdapterError::Io {
231            path: path.to_path_buf(),
232            source,
233        })?;
234        if !canonical.starts_with(&self.canonical_root) {
235            return Err(AdapterError::SymlinkEscape {
236                logical_path: path
237                    .strip_prefix(&self.root)
238                    .unwrap_or(path)
239                    .to_string_lossy()
240                    .into_owned(),
241            });
242        }
243        Ok(())
244    }
245}
246
247fn scan_root(context: &ScanContext) -> Result<PathBuf, AdapterError> {
248    match context.scope() {
249        Scope::Global => Ok(context.home().to_path_buf()),
250        Scope::Project => context
251            .workspace()
252            .map(Path::to_path_buf)
253            .ok_or(AdapterError::WorkspaceRequired),
254    }
255}
256
257fn protected_reason(class: ResourceClass) -> &'static str {
258    match class {
259        ResourceClass::MetadataOnly => "presence recorded without content",
260        ResourceClass::Excluded => "credential, session, history, cache, or control state",
261        ResourceClass::Migratable => "supported adapter resource",
262    }
263}