weavatrix-refactor-plan 0.1.1

Evidence metadata, validation profiles, and canonical fingerprints for Weavatrix refactor plans
Documentation
use crate::{Completeness, FileEdit, PlanError, PlanEvidence, PlanFingerprint, TextEdit};
use blazingly_json::Value;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

/// Stable wire schema owned by this crate.
pub const REFACTOR_PLAN_SCHEMA: &str = "weavatrix.refactor-plan.v1";

/// A bounded, versioned collection of logical refactor operations and evidence.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RefactorPlan {
    pub schema_version: String,
    pub operation: String,
    /// One simultaneous transition set; array order is identity, not execution order.
    pub operations: Vec<RefactorOperation>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub completeness: Option<Completeness>,
    #[serde(flatten)]
    pub evidence: PlanEvidence,
}

impl RefactorPlan {
    #[must_use]
    pub fn new(operation: impl Into<String>, operations: Vec<RefactorOperation>) -> Self {
        Self {
            schema_version: REFACTOR_PLAN_SCHEMA.to_owned(),
            operation: operation.into(),
            operations,
            completeness: None,
            evidence: PlanEvidence::default(),
        }
    }

    pub fn validate(&self) -> Result<crate::ValidatedConsumerPlan<'_>, PlanError> {
        crate::validate_consumer_plan(self, crate::RefactorPlanLimits::default())
    }

    pub fn validate_with(
        &self,
        limits: crate::RefactorPlanLimits,
    ) -> Result<crate::ValidatedConsumerPlan<'_>, PlanError> {
        crate::validate_consumer_plan(self, limits)
    }

    pub fn fingerprint(&self) -> Result<PlanFingerprint, PlanError> {
        crate::fingerprint_plan(self)
    }

    /// Converts a legacy edit envelope without losing text edits or extensions.
    ///
    /// # This requires a capturing decode
    ///
    /// The edit envelope declares only `schemaVersion`, `operation`, `files`,
    /// and `completeness`, so every annotation this crate understands —
    /// `createdAt`, `graphRevision`, `completenessProof`,
    /// `uncertainReferences`, `notModified`, `warnings`, `followUp`,
    /// `syntaxCheck` — arrives as an undeclared member and lives in
    /// `crate::EditPlan::extensions`.
    ///
    /// A plan decoded through [`crate::weavatrix_edit::DeclaredEditPlan`] has
    /// empty extension maps at every level. This conversion then returns `Ok`
    /// with [`PlanEvidence::default()`](crate::PlanEvidence), reporting neither
    /// an error nor a warning, the extension budget in
    /// [`validate_with`](Self::validate_with) has nothing left to weigh, and the
    /// [`fingerprint`](Self::fingerprint) differs from the one the same wire
    /// document yields after a capturing decode. Decode through
    /// [`crate::EditPlan`] whenever the evidence matters.
    pub fn from_text_edit_plan(plan: crate::EditPlan) -> Result<Self, PlanError> {
        crate::conversion::from_text_edit_plan(plan)
    }

    /// Converts to a legacy edit envelope if every operation is a text modify.
    pub fn try_into_text_edit_plan(self) -> Result<crate::EditPlan, PlanError> {
        crate::conversion::into_text_edit_plan(self)
    }
}

/// One logical operation in a refactor plan.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
#[serde(deny_unknown_fields)]
pub enum RefactorOperation {
    Modify(FileEdit),
    Create(CreateFile),
    Delete(DeleteFile),
    Rename(RenameFile),
}

/// Exact UTF-8 contents to create at a path that must be absent.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateFile {
    pub path: String,
    pub contents: String,
    #[serde(default, skip_serializing_if = "CreatePermissions::is_default")]
    pub permissions: CreatePermissions,
    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
    pub extensions: BTreeMap<String, Value>,
}

impl CreateFile {
    #[must_use]
    pub fn new(path: impl Into<String>, contents: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            contents: contents.into(),
            permissions: CreatePermissions::default(),
            extensions: BTreeMap::new(),
        }
    }

    #[must_use]
    pub const fn with_executable(mut self, executable: bool) -> Self {
        self.permissions.executable = executable;
        self
    }
}

/// Deterministic portable permission policy for a newly created source file.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub struct CreatePermissions {
    pub executable: bool,
}

impl CreatePermissions {
    #[must_use]
    pub const fn is_default(&self) -> bool {
        !self.executable
    }

    #[must_use]
    pub const fn readonly(self) -> bool {
        false
    }

    #[must_use]
    pub const fn unix_mode(self) -> u32 {
        if self.executable { 0o755 } else { 0o644 }
    }
}

/// Delete an existing file only when its complete contents match.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteFile {
    pub path: String,
    pub expected_sha256: String,
    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
    pub extensions: BTreeMap<String, Value>,
}

impl DeleteFile {
    #[must_use]
    pub fn new(path: impl Into<String>, expected_sha256: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            expected_sha256: expected_sha256.into(),
            extensions: BTreeMap::new(),
        }
    }
}

/// Move one exact source to an absent destination.
///
/// `edits` use v1 UTF-16 coordinates against the original `from` contents
/// guarded by `expected_source_sha256`, before the move.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameFile {
    pub from: String,
    pub to: String,
    pub expected_source_sha256: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub edits: Vec<TextEdit>,
    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
    pub extensions: BTreeMap<String, Value>,
}

impl RenameFile {
    #[must_use]
    pub fn new(
        from: impl Into<String>,
        to: impl Into<String>,
        expected_source_sha256: impl Into<String>,
    ) -> Self {
        Self {
            from: from.into(),
            to: to.into(),
            expected_source_sha256: expected_source_sha256.into(),
            edits: Vec::new(),
            extensions: BTreeMap::new(),
        }
    }

    #[must_use]
    pub fn with_edits(mut self, edits: Vec<TextEdit>) -> Self {
        self.edits = edits;
        self
    }
}