agent-migrate-adapters 0.6.1

Codex, Pi, and Claude Code configuration adapters for agent-migrate
Documentation
use std::fmt::Write as _;
use std::path::Path;

use agent_migrate_core::{
    AgentKind, BundleRelativePath, ChangeKind, Compatibility, ConflictPolicy, FileKind, SecretRef,
    WritePlan,
};
use serde::Serialize;

use crate::AdapterError;

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResourceConversion {
    pub source_path: BundleRelativePath,
    pub kind: FileKind,
    pub compatibility: Compatibility,
    pub target_path: Option<BundleRelativePath>,
    pub change: ChangeKind,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct ReportSummary {
    pub added: usize,
    pub identical: usize,
    pub merged: usize,
    pub replaced: usize,
    pub renamed: usize,
    pub skipped: usize,
    pub conflicts: usize,
    pub warnings: usize,
    pub blocked: usize,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ConversionReport {
    pub schema_version: &'static str,
    pub source: AgentKind,
    pub target: AgentKind,
    pub resources: Vec<ResourceConversion>,
    pub secret_references: Vec<SecretRef>,
    pub summary: ReportSummary,
}

impl ConversionReport {
    pub(crate) fn new(
        source: AgentKind,
        target: AgentKind,
        resources: Vec<ResourceConversion>,
        mut secret_references: Vec<SecretRef>,
    ) -> Self {
        secret_references.sort_by(|left, right| {
            left.logical_location
                .cmp(&right.logical_location)
                .then_with(|| left.name.cmp(&right.name))
        });
        let mut report = Self {
            schema_version: "1.0",
            source,
            target,
            resources,
            secret_references,
            summary: ReportSummary::default(),
        };
        report.recount();
        report
    }

    /// Classifies planned writes against a target root.
    ///
    /// # Errors
    ///
    /// Returns an error when an existing target resource cannot be read.
    pub fn classify_changes(
        &mut self,
        root: &Path,
        plan: &WritePlan,
        policy: ConflictPolicy,
    ) -> Result<(), AdapterError> {
        for resource in &mut self.resources {
            resource.change = classify(resource.target_path.as_ref(), root, plan, policy)?;
        }
        self.recount();
        Ok(())
    }

    #[must_use]
    pub fn to_markdown(&self) -> String {
        let mut output = format!(
            "# Agent Config Migrate Report\n\nSchema: {}\n\nSource: `{}`  \nTarget: `{}`\n\n| Change | Kind | Source | Target | Compatibility |\n| --- | --- | --- | --- | --- |\n",
            self.schema_version, self.source, self.target
        );
        for resource in &self.resources {
            if writeln!(
                output,
                "| {:?} | {:?} | `{}` | `{}` | `{}` |",
                resource.change,
                resource.kind,
                escape(&resource.source_path.to_string()),
                resource
                    .target_path
                    .as_ref()
                    .map_or_else(|| "-".to_owned(), ToString::to_string),
                compatibility_name(&resource.compatibility),
            )
            .is_err()
            {
                return output;
            }
        }
        output.push_str("\n## Required Secrets\n\n");
        for reference in &self.secret_references {
            if writeln!(
                output,
                "- `{}` at `{}` (required: {})",
                escape(&reference.name),
                escape(&reference.logical_location),
                reference.required
            )
            .is_err()
            {
                return output;
            }
        }
        output
    }

    fn recount(&mut self) {
        let mut summary = ReportSummary::default();
        for resource in &self.resources {
            match resource.change {
                ChangeKind::Add => summary.added += 1,
                ChangeKind::Identical => summary.identical += 1,
                ChangeKind::Merge => summary.merged += 1,
                ChangeKind::Replace => summary.replaced += 1,
                ChangeKind::Rename => summary.renamed += 1,
                ChangeKind::Skip => summary.skipped += 1,
                ChangeKind::Conflict => summary.conflicts += 1,
            }
            match &resource.compatibility {
                Compatibility::Lossy { .. } | Compatibility::PreservedUnavailable { .. } => {
                    summary.warnings += 1;
                }
                Compatibility::Blocked { .. } => summary.blocked += 1,
                Compatibility::Exact | Compatibility::Converted | Compatibility::Bridged { .. } => {
                }
            }
        }
        self.summary = summary;
    }
}

fn classify(
    path: Option<&BundleRelativePath>,
    root: &Path,
    plan: &WritePlan,
    policy: ConflictPolicy,
) -> Result<ChangeKind, AdapterError> {
    let Some(path) = path else {
        return Ok(ChangeKind::Skip);
    };
    let Some(write) = plan.get(path) else {
        return Ok(ChangeKind::Skip);
    };
    let target = root.join(path.as_path());
    if !target.exists() {
        return Ok(ChangeKind::Add);
    }
    let existing = std::fs::read(&target).map_err(|source| AdapterError::Io {
        path: target,
        source,
    })?;
    if existing == write.content {
        return Ok(ChangeKind::Identical);
    }
    Ok(match policy {
        ConflictPolicy::Fail => ChangeKind::Conflict,
        ConflictPolicy::Replace => ChangeKind::Replace,
        ConflictPolicy::Merge => ChangeKind::Merge,
        ConflictPolicy::Rename => ChangeKind::Rename,
    })
}

fn compatibility_name(value: &Compatibility) -> &'static str {
    match value {
        Compatibility::Exact => "exact",
        Compatibility::Converted => "converted",
        Compatibility::Bridged { .. } => "bridged",
        Compatibility::Lossy { .. } => "lossy",
        Compatibility::PreservedUnavailable { .. } => "preserved_unavailable",
        Compatibility::Blocked { .. } => "blocked",
    }
}

fn escape(value: &str) -> String {
    value.replace(['\n', '\r'], " ").replace('|', "\\|")
}