agent-migrate-core 0.6.1

Core models and transactional filesystem engine for agent configuration migration
Documentation
use std::path::{Component, Path, PathBuf};

use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PathMapping {
    source: PathBuf,
    target: PathBuf,
}

impl PathMapping {
    /// Creates a normalized absolute path mapping.
    ///
    /// # Errors
    ///
    /// Returns an error if either side is not normalized and absolute.
    pub fn new(source: PathBuf, target: PathBuf) -> Result<Self, PathMappingError> {
        if !valid_absolute(&source) || !valid_absolute(&target) {
            return Err(PathMappingError::Invalid);
        }
        Ok(Self { source, target })
    }

    #[must_use]
    pub fn source(&self) -> &Path {
        &self.source
    }

    #[must_use]
    pub fn target(&self) -> &Path {
        &self.target
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathMappingSet {
    mappings: Vec<PathMapping>,
}

impl PathMappingSet {
    /// Creates a deterministic longest-prefix mapping set.
    ///
    /// # Errors
    ///
    /// Returns an error when two mappings declare the same source.
    pub fn new(mut mappings: Vec<PathMapping>) -> Result<Self, PathMappingError> {
        mappings.sort_by_key(|mapping| std::cmp::Reverse(mapping.source.components().count()));
        for pair in mappings.windows(2) {
            if pair[0].source == pair[1].source {
                return Err(PathMappingError::DuplicateSource);
            }
        }
        Ok(Self { mappings })
    }

    /// Maps an absolute path using component-boundary prefix matching.
    ///
    /// # Errors
    ///
    /// Returns an error for invalid input or when no mapping applies.
    pub fn map(&self, input: &Path) -> Result<PathBuf, PathMappingError> {
        if !valid_absolute(input) {
            return Err(PathMappingError::Invalid);
        }
        self.mappings
            .iter()
            .find_map(|mapping| {
                input
                    .strip_prefix(&mapping.source)
                    .ok()
                    .map(|suffix| mapping.target.join(suffix))
            })
            .ok_or(PathMappingError::Unmapped)
    }
}

fn valid_absolute(path: &Path) -> bool {
    path.is_absolute()
        && path
            .components()
            .all(|component| !matches!(component, Component::ParentDir))
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum PathMappingError {
    #[error("path mapping must use normalized absolute paths")]
    Invalid,
    #[error("path mapping contains a duplicate source")]
    DuplicateSource,
    #[error("absolute path has no declared mapping")]
    Unmapped,
}