agent-migrate-adapters 0.6.1

Codex, Pi, and Claude Code configuration adapters for agent-migrate
Documentation
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;
    /// Discovers resources within the declared global or project boundary.
    ///
    /// # Errors
    ///
    /// Returns an error for missing workspace, filesystem failure, or path escape.
    fn scan(&self, context: &ScanContext) -> Result<Inventory, AdapterError>;
    /// Exports a verified and sanitized canonical Bundle.
    ///
    /// # Errors
    ///
    /// Returns an error when discovery, parsing, redaction, or Bundle verification fails.
    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",
    }
}