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 fn scan(&self, context: &ScanContext) -> Result<Inventory, AdapterError>;
25 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}