Skip to main content

weavatrix_edit/
validation.rs

1mod files;
2
3use std::collections::BTreeMap;
4
5use blazingly_json::Value;
6
7use crate::{
8    error::{EditError, ErrorCode},
9    limits::{MAX_PLAN_OPERATION_BYTES, PlanLimits},
10    model::{Completeness, EDIT_PLAN_SCHEMA, EditPlan, FileEdit, TextEdit},
11};
12
13pub(crate) use files::validate_text_edit;
14
15/// Reserved JSON member names for a [`FileEdit`] extension map.
16pub const FILE_EDIT_RESERVED_EXTENSION_KEYS: &[&str] = &["path", "sha256", "edits"];
17
18/// Zero-copy view of one exact file edit set.
19#[derive(Clone, Copy, Debug)]
20pub struct BorrowedFileEdit<'file> {
21    pub path: &'file str,
22    pub sha256: &'file str,
23    pub edits: &'file [TextEdit],
24    pub extensions: &'file BTreeMap<String, Value>,
25    /// Member names which the source envelope owns and extensions may not shadow.
26    pub reserved_extension_keys: &'file [&'file str],
27}
28
29impl<'file> From<&'file FileEdit> for BorrowedFileEdit<'file> {
30    fn from(file: &'file FileEdit) -> Self {
31        Self {
32            path: &file.path,
33            sha256: &file.sha256,
34            edits: &file.edits,
35            extensions: &file.extensions,
36            reserved_extension_keys: FILE_EDIT_RESERVED_EXTENSION_KEYS,
37        }
38    }
39}
40
41/// Owned statistics produced by zero-copy file-edit validation.
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub struct EditValidationStats {
44    total_edits: usize,
45    total_text_bytes: usize,
46}
47
48impl EditValidationStats {
49    #[must_use]
50    pub const fn total_edits(self) -> usize {
51        self.total_edits
52    }
53
54    #[must_use]
55    pub const fn total_text_bytes(self) -> usize {
56        self.total_text_bytes
57    }
58}
59
60/// Proof that an edit plan passed structural, evidence, path, and budget checks.
61#[derive(Clone, Copy, Debug)]
62pub struct ValidatedEditPlan<'plan> {
63    plan: &'plan EditPlan,
64    stats: EditValidationStats,
65}
66
67impl<'plan> ValidatedEditPlan<'plan> {
68    #[must_use]
69    pub const fn plan(self) -> &'plan EditPlan {
70        self.plan
71    }
72
73    #[must_use]
74    pub const fn total_edits(self) -> usize {
75        self.stats.total_edits()
76    }
77
78    #[must_use]
79    pub const fn total_text_bytes(self) -> usize {
80        self.stats.total_text_bytes()
81    }
82}
83
84/// Validates a frozen edit-plan envelope and its borrowed file/edit contents.
85pub fn validate_edit_plan(
86    plan: &EditPlan,
87    limits: PlanLimits,
88) -> Result<ValidatedEditPlan<'_>, EditError> {
89    if plan.schema_version != EDIT_PLAN_SCHEMA {
90        return Err(EditError::new(
91            ErrorCode::SchemaMismatch,
92            format!("schemaVersion must be {EDIT_PLAN_SCHEMA}"),
93        ));
94    }
95    files::validate_extension_keys(
96        &plan.extensions,
97        &["schemaVersion", "operation", "files", "completeness"],
98        ErrorCode::InvalidPlan,
99    )?;
100    validate_collection(&plan.operation, plan.files.len(), limits)?;
101    validate_completeness(plan.completeness.as_ref())?;
102    let stats = files::validate_file_views(plan.files.iter().map(BorrowedFileEdit::from), limits)?;
103    Ok(ValidatedEditPlan { plan, stats })
104}
105
106/// Validates arbitrary borrowed file edits with the same engine as [`EditPlan`].
107///
108/// This entry point owns no schema envelope or completeness claim. It validates
109/// the operation label, file/edit structures, paths, hashes, provenance,
110/// uniqueness, and every [`PlanLimits`] budget without cloning edit text.
111pub fn validate_file_edits(
112    operation: &str,
113    files: &[BorrowedFileEdit<'_>],
114    limits: PlanLimits,
115) -> Result<EditValidationStats, EditError> {
116    validate_collection(operation, files.len(), limits)?;
117    files::validate_file_views(files.iter().copied(), limits)
118}
119
120fn validate_collection(
121    operation: &str,
122    file_count: usize,
123    limits: PlanLimits,
124) -> Result<(), EditError> {
125    if operation.is_empty() {
126        return Err(EditError::new(
127            ErrorCode::InvalidPlan,
128            "plan.operation is required",
129        ));
130    }
131    if operation.len() > MAX_PLAN_OPERATION_BYTES {
132        return Err(too_large(format!(
133            "plan.operation exceeds the {MAX_PLAN_OPERATION_BYTES}-byte limit"
134        )));
135    }
136    if file_count == 0 {
137        return Err(EditError::new(
138            ErrorCode::InvalidPlan,
139            "plan.files must be non-empty",
140        ));
141    }
142    if file_count > limits.max_files {
143        return Err(too_large(format!(
144            "plan touches more than {} files",
145            limits.max_files
146        )));
147    }
148    Ok(())
149}
150
151fn validate_completeness(completeness: Option<&Completeness>) -> Result<(), EditError> {
152    let Some(completeness) = completeness else {
153        return Ok(());
154    };
155    if !matches!(
156        completeness.as_str(),
157        Completeness::COMPLETE | Completeness::PARTIAL
158    ) {
159        return Err(EditError::new(
160            ErrorCode::InvalidPlan,
161            "plan.completeness must be COMPLETE or PARTIAL",
162        ));
163    }
164    Ok(())
165}
166
167pub(super) fn too_large(message: impl Into<String>) -> EditError {
168    EditError::new(ErrorCode::PlanTooLarge, message)
169}