use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use agent_migrate_core::{
AgentKind, Bundle, BundleRelativePath, FileKind, Manifest, Scope, SourceDescriptor,
SourceEnvironment,
};
use walkdir::WalkDir;
use crate::context::{ExportContext, ScanContext};
use crate::error::AdapterError;
use crate::inventory::{Inventory, MigratableLocation, ResourceClass, ResourceEntry};
use crate::layout::{Candidate, candidates};
use crate::sanitize::sanitize;
pub trait Adapter {
fn kind(&self) -> AgentKind;
fn scan(&self, context: &ScanContext) -> Result<Inventory, AdapterError>;
fn export(&self, context: &ExportContext) -> Result<Bundle, AdapterError>;
}
#[derive(Debug, Clone, Copy)]
pub struct AgentAdapter {
kind: AgentKind,
}
#[must_use]
pub const fn adapter_for(kind: AgentKind) -> AgentAdapter {
AgentAdapter { kind }
}
impl Adapter for AgentAdapter {
fn kind(&self) -> AgentKind {
self.kind
}
fn scan(&self, context: &ScanContext) -> Result<Inventory, AdapterError> {
let root = scan_root(context)?;
let canonical_root = root.canonicalize().map_err(|source| AdapterError::Io {
path: root.clone(),
source,
})?;
let mut scanner = Scanner::new(self.kind, root, canonical_root);
for candidate in candidates(self.kind, context.scope()) {
scanner.collect(*candidate)?;
}
Ok(Inventory::new(self.kind, context.scope(), scanner.entries))
}
fn export(&self, context: &ExportContext) -> Result<Bundle, AdapterError> {
let inventory = self.scan(context.scan())?;
let mut files = BTreeMap::new();
let mut secret_references = Vec::new();
for entry in inventory
.entries
.iter()
.filter(|entry| entry.class == ResourceClass::Migratable)
{
let source_path = entry.source_path().ok_or(AdapterError::InternalInvariant)?;
let bundle_path = entry
.bundle_path
.clone()
.ok_or(AdapterError::InternalInvariant)?;
let kind = entry.kind.ok_or(AdapterError::InternalInvariant)?;
let bytes = fs::read(source_path).map_err(|source| AdapterError::Io {
path: source_path.to_path_buf(),
source,
})?;
let mode = portable_mode(source_path)?;
let mut sanitized = sanitize(source_path, kind, &bytes)?;
for reference in &mut sanitized.references {
reference.logical_location = format!(
"{}{}",
bundle_path,
reference.logical_location.trim_start_matches('$')
);
}
secret_references.extend(sanitized.references);
if let Some(mcp) = sanitized.mcp {
files.insert(mcp_path(&bundle_path)?, (FileKind::Mcp, 0o600, mcp));
}
files.insert(bundle_path, (kind, mode, sanitized.content));
}
let environment = SourceEnvironment::new(
context.agent_version(),
context.scan().scope(),
format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
);
let source = SourceDescriptor::new(self.kind, environment);
let manifest =
Manifest::new(context.identity(), source).with_secret_references(secret_references);
Bundle::from_files_with_modes(manifest, files).map_err(Into::into)
}
}
#[cfg(unix)]
fn portable_mode(path: &Path) -> Result<u32, AdapterError> {
use std::os::unix::fs::PermissionsExt;
fs::metadata(path)
.map(|metadata| metadata.permissions().mode() & 0o777)
.map_err(|source| AdapterError::Io {
path: path.to_path_buf(),
source,
})
}
#[cfg(not(unix))]
fn portable_mode(_path: &Path) -> Result<u32, AdapterError> {
Ok(0o600)
}
fn mcp_path(settings: &BundleRelativePath) -> Result<BundleRelativePath, AdapterError> {
let value = settings.to_string();
let suffix = value
.strip_prefix("settings/")
.ok_or(AdapterError::InternalInvariant)?;
format!("mcp/{suffix}").parse().map_err(Into::into)
}
#[derive(Debug)]
struct Scanner {
agent: AgentKind,
root: PathBuf,
canonical_root: PathBuf,
entries: Vec<ResourceEntry>,
}
impl Scanner {
fn new(agent: AgentKind, root: PathBuf, canonical_root: PathBuf) -> Self {
Self {
agent,
root,
canonical_root,
entries: Vec::new(),
}
}
fn collect(&mut self, candidate: Candidate) -> Result<(), AdapterError> {
let path = self.root.join(candidate.path);
if !path.exists() && !path.is_symlink() {
return Ok(());
}
match candidate.class {
ResourceClass::Migratable if candidate.tree => self.collect_tree(candidate, &path),
ResourceClass::Migratable => self.collect_file(candidate, path),
ResourceClass::MetadataOnly | ResourceClass::Excluded => {
let index = self.entries.len();
self.entries.push(ResourceEntry::protected(
candidate.class,
index,
protected_reason(candidate.class),
));
Ok(())
}
}
}
fn collect_tree(&mut self, candidate: Candidate, root: &Path) -> Result<(), AdapterError> {
for item in WalkDir::new(root).follow_links(false).min_depth(1) {
let entry = item.map_err(|error| AdapterError::Walk(error.to_string()))?;
if entry.file_type().is_dir() {
continue;
}
self.collect_file(candidate, entry.path().to_path_buf())?;
}
Ok(())
}
fn collect_file(&mut self, candidate: Candidate, path: PathBuf) -> Result<(), AdapterError> {
self.ensure_contained(&path)?;
let logical = path
.strip_prefix(&self.root)
.map_err(|_| AdapterError::InternalInvariant)?;
let kind = candidate.kind.ok_or(AdapterError::InternalInvariant)?;
let bundle_path = self.bundle_path(candidate, logical)?;
self.entries.push(ResourceEntry::migratable(
MigratableLocation {
logical: logical.to_string_lossy().into_owned(),
source: path,
bundle: bundle_path,
},
kind,
));
Ok(())
}
fn bundle_path(
&self,
candidate: Candidate,
logical: &Path,
) -> Result<BundleRelativePath, AdapterError> {
let source_root = Path::new(candidate.path);
let suffix = logical.strip_prefix(source_root).unwrap_or(logical);
let suffix = if suffix.as_os_str().is_empty() {
logical
.file_name()
.map(Path::new)
.ok_or(AdapterError::InternalInvariant)?
} else {
suffix
};
let kind = candidate.kind.ok_or(AdapterError::InternalInvariant)?;
let category = match kind {
FileKind::Skill => "skills",
FileKind::Mcp => "mcp",
FileKind::Instructions => "instructions",
FileKind::Command => "commands",
FileKind::Agent => "agents",
FileKind::Hook => "hooks",
FileKind::Settings => "settings",
FileKind::Passthrough => "passthrough",
};
let path = format!("{category}/{}/{}", self.agent, suffix.to_string_lossy());
path.parse().map_err(Into::into)
}
fn ensure_contained(&self, path: &Path) -> Result<(), AdapterError> {
let canonical = path.canonicalize().map_err(|source| AdapterError::Io {
path: path.to_path_buf(),
source,
})?;
if !canonical.starts_with(&self.canonical_root) {
return Err(AdapterError::SymlinkEscape {
logical_path: path
.strip_prefix(&self.root)
.unwrap_or(path)
.to_string_lossy()
.into_owned(),
});
}
Ok(())
}
}
fn scan_root(context: &ScanContext) -> Result<PathBuf, AdapterError> {
match context.scope() {
Scope::Global => Ok(context.home().to_path_buf()),
Scope::Project => context
.workspace()
.map(Path::to_path_buf)
.ok_or(AdapterError::WorkspaceRequired),
}
}
fn protected_reason(class: ResourceClass) -> &'static str {
match class {
ResourceClass::MetadataOnly => "presence recorded without content",
ResourceClass::Excluded => "credential, session, history, cache, or control state",
ResourceClass::Migratable => "supported adapter resource",
}
}