Skip to main content

claw_branch/commit/
validator.rs

1//! Pre-commit validation for cherry-pick operations.
2
3use sqlx::{
4    sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
5    SqlitePool,
6};
7
8use crate::{
9    commit::cherry::CherryPick,
10    error::BranchResult,
11    types::{Branch, BranchStatus},
12};
13
14/// Validates cherry-pick commits before any mutation is applied.
15///
16/// # Example
17/// ```rust,ignore
18/// let report = CommitValidator::new(pool).validate(&cherry, &source, &target).await?;
19/// if !report.ok { return Err(...); }
20/// ```
21pub struct CommitValidator {
22    pool: SqlitePool,
23}
24
25/// The result of a pre-commit validation pass.
26#[derive(Debug, Clone)]
27pub struct ValidationReport {
28    /// True when no violations were detected.
29    pub ok: bool,
30    /// Hard violations that prevent the commit from proceeding.
31    pub violations: Vec<String>,
32    /// Non-fatal warnings that operators should review.
33    pub warnings: Vec<String>,
34}
35
36impl CommitValidator {
37    /// Creates a new validator backed by the given SQLite pool (source branch).
38    pub fn new(pool: SqlitePool) -> Self {
39        Self { pool }
40    }
41
42    /// Opens a validator from a branch db_path.
43    pub async fn from_branch(branch: &Branch) -> BranchResult<Self> {
44        let pool = SqlitePoolOptions::new()
45            .max_connections(1)
46            .connect_with(
47                SqliteConnectOptions::new()
48                    .filename(&branch.db_path)
49                    .create_if_missing(false)
50                    .read_only(true)
51                    .journal_mode(SqliteJournalMode::Wal),
52            )
53            .await?;
54        Ok(Self { pool })
55    }
56
57    /// Validates the cherry-pick against the source and target branches.
58    ///
59    /// Checks performed:
60    /// - Source branch must be `Active` or `Dormant`
61    /// - Target branch must be `Active`
62    /// - All specified entity ids must exist in the source database
63    /// - No obvious foreign-key invariant violations in the target
64    pub async fn validate(
65        &self,
66        cherry: &CherryPick,
67        source: &Branch,
68        target: &Branch,
69    ) -> BranchResult<ValidationReport> {
70        let mut violations: Vec<String> = Vec::new();
71        let mut warnings: Vec<String> = Vec::new();
72
73        // Status checks.
74        match &source.status {
75            BranchStatus::Active | BranchStatus::Dormant => {}
76            other => violations.push(format!(
77                "source branch {} is {:?}, must be Active or Dormant",
78                source.id,
79                other.kind()
80            )),
81        }
82        if !matches!(&target.status, BranchStatus::Active) {
83            violations.push(format!(
84                "target branch {} is {:?}, must be Active",
85                target.id,
86                target.status.kind()
87            ));
88        }
89
90        // Entity existence checks.
91        for sel in &cherry.entity_selections {
92            if sel.entity_ids.is_empty() {
93                continue; // All-of-type selections skip id validation.
94            }
95            let table = sel.entity_type.table_name();
96            let table_exists: bool = sqlx::query_scalar::<_, i64>(
97                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
98            )
99            .bind(table)
100            .fetch_one(&self.pool)
101            .await
102            .map(|n| n > 0)
103            .unwrap_or(false);
104
105            if !table_exists {
106                warnings.push(format!(
107                    "table '{table}' does not exist in source; skipping entity existence check"
108                ));
109                continue;
110            }
111
112            for entity_id in &sel.entity_ids {
113                let exists: bool = sqlx::query_scalar::<_, i64>(&format!(
114                    "SELECT COUNT(*) FROM {table} WHERE id = ?"
115                ))
116                .bind(entity_id)
117                .fetch_one(&self.pool)
118                .await
119                .map(|n| n > 0)
120                .unwrap_or(false);
121
122                if !exists {
123                    violations.push(format!(
124                        "entity '{}' of type {:?} not found in source branch {}",
125                        entity_id, sel.entity_type, source.id
126                    ));
127                }
128            }
129        }
130
131        let ok = violations.is_empty();
132        Ok(ValidationReport {
133            ok,
134            violations,
135            warnings,
136        })
137    }
138}