agent-migrate-core 0.6.1

Core models and transactional filesystem engine for agent configuration migration
Documentation
use std::path::Path;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use uuid::Uuid;

use crate::Endpoint;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatchDecision {
    Apply,
    Unchanged,
    SuppressedOrigin,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OriginStamp {
    origin_id: Uuid,
    content_hash: String,
}

impl OriginStamp {
    #[must_use]
    pub fn new(origin_id: Uuid, content_hash: impl Into<String>) -> Self {
        Self {
            origin_id,
            content_hash: content_hash.into(),
        }
    }

    #[must_use]
    pub const fn origin_id(&self) -> Uuid {
        self.origin_id
    }

    #[must_use]
    pub fn matches(&self, content_hash: &str) -> bool {
        self.content_hash == content_hash
    }

    /// Saves an origin stamp through an atomic same-directory replacement.
    ///
    /// # Errors
    ///
    /// Returns an error when serialization or durable filesystem operations fail.
    pub fn save(&self, path: &Path) -> Result<(), WatchError> {
        save_json(self, path)
    }

    /// Loads an origin stamp from JSON.
    ///
    /// # Errors
    ///
    /// Returns an error when reading or parsing state fails.
    pub fn load(path: &Path) -> Result<Self, WatchError> {
        load_json(path)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WatchState {
    origin_id: Uuid,
    source: Endpoint,
    target: Endpoint,
    last_applied_hash: Option<String>,
}

impl WatchState {
    #[must_use]
    pub fn new(source: Endpoint, target: Endpoint) -> Self {
        let origin_id = relationship_id(&source, &target);
        Self {
            origin_id,
            source,
            target,
            last_applied_hash: None,
        }
    }

    #[must_use]
    pub const fn origin_id(&self) -> Uuid {
        self.origin_id
    }

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

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

    #[must_use]
    pub fn decide(&self, hash: &str, incoming_origin: Option<Uuid>) -> WatchDecision {
        if incoming_origin == Some(self.origin_id) {
            WatchDecision::SuppressedOrigin
        } else if self.last_applied_hash.as_deref() == Some(hash) {
            WatchDecision::Unchanged
        } else {
            WatchDecision::Apply
        }
    }

    pub fn record_applied(&mut self, hash: impl Into<String>) {
        self.last_applied_hash = Some(hash.into());
    }

    /// Saves watcher state through an atomic same-directory replacement.
    ///
    /// # Errors
    ///
    /// Returns an error when serialization or durable filesystem operations fail.
    pub fn save(&self, path: &Path) -> Result<(), WatchError> {
        save_json(self, path)
    }

    /// Loads watcher state from its JSON file.
    ///
    /// # Errors
    ///
    /// Returns an error when reading or parsing state fails.
    pub fn load(path: &Path) -> Result<Self, WatchError> {
        load_json(path)
    }
}

fn relationship_id(source: &Endpoint, target: &Endpoint) -> Uuid {
    let source = source.to_string();
    let target = target.to_string();
    let (first, second) = if source <= target {
        (source, target)
    } else {
        (target, source)
    };
    let digest = Sha256::digest(format!("{first}\0{second}").as_bytes());
    let mut bytes = [0_u8; 16];
    bytes.copy_from_slice(&digest[..16]);
    Uuid::from_bytes(bytes)
}

#[must_use]
pub fn content_hash(content: &[u8]) -> String {
    hex::encode(Sha256::digest(content))
}

fn save_json(value: &impl Serialize, path: &Path) -> Result<(), WatchError> {
    let parent = path.parent().ok_or(WatchError::MissingParent)?;
    std::fs::create_dir_all(parent)?;
    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
    serde_json::to_writer_pretty(temporary.as_file_mut(), value)?;
    temporary.as_file().sync_all()?;
    temporary.persist(path).map_err(|error| error.error)?;
    Ok(())
}

fn load_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, WatchError> {
    let bytes = std::fs::read(path)?;
    serde_json::from_slice(&bytes).map_err(Into::into)
}

#[derive(Debug, Error)]
pub enum WatchError {
    #[error("watch state I/O failed")]
    Io(#[from] std::io::Error),
    #[error("watch state JSON is invalid")]
    Json(#[from] serde_json::Error),
    #[error("watch state path has no parent directory")]
    MissingParent,
}