agent-migrate-core 0.6.1

Core models and transactional filesystem engine for agent configuration migration
Documentation
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;

use crate::BundleRelativePath;
use crate::transaction::{ConflictPolicy, PlannedWrite, TransactionError, WritePlan};

pub(super) fn resolve_renames(
    plan: &WritePlan,
    root: &Path,
) -> Result<WritePlan, TransactionError> {
    let mappings = collect_mappings(plan, root)?;
    let mut resolved = WritePlan::default();
    for write in plan.writes() {
        let path = apply_mapping(write.path.as_path(), &mappings);
        let relative = parse_relative(&path)?;
        resolved.insert(PlannedWrite::new(relative, write.content.clone()).with_mode(write.mode));
    }
    Ok(resolved)
}

fn collect_mappings(
    plan: &WritePlan,
    root: &Path,
) -> Result<BTreeMap<PathBuf, PathBuf>, TransactionError> {
    let mut mappings = BTreeMap::new();
    for write in plan.writes() {
        if root.join(write.path.as_path()).exists() {
            let prefix = resource_prefix(write.path.as_path()).ok_or(
                TransactionError::UnsupportedConflictPolicy(ConflictPolicy::Rename),
            )?;
            if let Entry::Vacant(entry) = mappings.entry(prefix.clone()) {
                let renamed = available_prefix(root, &prefix)?;
                let _inserted = entry.insert(renamed);
            }
        }
    }
    Ok(mappings)
}

fn available_prefix(root: &Path, prefix: &Path) -> Result<PathBuf, TransactionError> {
    for suffix in 1..=999 {
        let renamed = renamed_resource_prefix(prefix, suffix).ok_or(
            TransactionError::UnsupportedConflictPolicy(ConflictPolicy::Rename),
        )?;
        if !root.join(&renamed).exists() {
            return Ok(renamed);
        }
    }
    Err(TransactionError::RenameExhausted {
        path: prefix.to_path_buf(),
    })
}

fn apply_mapping(path: &Path, mappings: &BTreeMap<PathBuf, PathBuf>) -> PathBuf {
    mappings
        .iter()
        .find_map(|(source, target)| {
            path.strip_prefix(source)
                .ok()
                .map(|suffix| target.join(suffix))
        })
        .unwrap_or_else(|| path.to_path_buf())
}

fn resource_prefix(path: &Path) -> Option<PathBuf> {
    let components = path
        .components()
        .map(Component::as_os_str)
        .collect::<Vec<_>>();
    let marker = components.iter().position(|part| {
        part.to_str()
            .is_some_and(|value| matches!(value, "skills" | "commands" | "agents"))
    })?;
    Some(components.iter().take(marker + 2).collect())
}

fn renamed_resource_prefix(path: &Path, suffix: u16) -> Option<PathBuf> {
    let mut components = path
        .components()
        .map(Component::as_os_str)
        .collect::<Vec<_>>();
    let name = components.last()?.to_str()?;
    let replacement = rename_component(name, suffix);
    let last = components.len().checked_sub(1)?;
    components[last] = replacement.as_ref();
    Some(components.iter().collect())
}

fn rename_component(name: &str, suffix: u16) -> std::ffi::OsString {
    let tag = if suffix == 1 {
        "-migrated".to_owned()
    } else {
        format!("-migrated-{suffix}")
    };
    let path = Path::new(name);
    match (path.file_stem(), path.extension()) {
        (Some(stem), Some(extension)) => {
            let mut value = stem.to_os_string();
            value.push(tag);
            value.push(".");
            value.push(extension);
            value
        }
        _ => format!("{name}{tag}").into(),
    }
}

fn parse_relative(path: &Path) -> Result<BundleRelativePath, TransactionError> {
    let value = path
        .to_str()
        .ok_or(TransactionError::UnsupportedConflictPolicy(
            ConflictPolicy::Rename,
        ))?;
    BundleRelativePath::from_str(value).map_err(|source| TransactionError::InvalidResolvedPath {
        message: source.to_string(),
    })
}