ccd-cli 1.0.0-beta.3

Bootstrap and validate Continuous Context Development repositories
use anyhow::Result;
use rusqlite::Connection;

use crate::state::session_gates::ExecutionGateStateFile;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExclusiveWriteResult {
    Applied { revision: u64 },
    RevisionConflict { current_revision: u64 },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExclusiveDeleteResult {
    Applied,
    IdempotentNoop,
    RevisionConflict { current_revision: u64 },
}

pub(crate) fn read(conn: &Connection) -> Result<Option<ExecutionGateStateFile>> {
    let mut stmt = conn.prepare(
        "SELECT schema_version, seeded_from, gates, revision
         FROM execution_gates WHERE id = 1",
    )?;

    let result = stmt.query_row([], |row| {
        let gates_json: String = row.get(2)?;
        let gates = serde_json::from_str(&gates_json).map_err(|error| {
            rusqlite::Error::FromSqlConversionFailure(
                2,
                rusqlite::types::Type::Text,
                Box::new(error),
            )
        })?;

        Ok(ExecutionGateStateFile {
            schema_version: row.get(0)?,
            seeded_from: row.get(1)?,
            gates,
            revision: row.get(3)?,
        })
    });

    match result {
        Ok(state) => Ok(Some(state)),
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
        Err(error) => Err(error.into()),
    }
}

pub(crate) fn current_revision(conn: &Connection) -> Result<u64> {
    let result = conn.query_row(
        "SELECT revision FROM execution_gates WHERE id = 1",
        [],
        |row| row.get(0),
    );
    match result {
        Ok(revision) => Ok(revision),
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(0),
        Err(error) => Err(error.into()),
    }
}

pub(crate) fn write(conn: &Connection, state: &ExecutionGateStateFile) -> Result<u64> {
    let next_revision = current_revision(conn)?.saturating_add(1).max(1);
    let gates_json = serde_json::to_string(&state.gates)?;
    conn.execute(
        "INSERT OR REPLACE INTO execution_gates
            (id, schema_version, seeded_from, gates, revision)
         VALUES (1, ?1, ?2, ?3, ?4)",
        rusqlite::params![
            state.schema_version,
            state.seeded_from,
            gates_json,
            next_revision
        ],
    )?;
    Ok(next_revision)
}

pub(crate) fn write_if_revision_matches(
    conn: &Connection,
    state: &ExecutionGateStateFile,
    expected_revision: u64,
) -> Result<ExclusiveWriteResult> {
    let gates_json = serde_json::to_string(&state.gates)?;
    if expected_revision == 0 {
        let inserted = conn.execute(
            "INSERT OR IGNORE INTO execution_gates
                (id, schema_version, seeded_from, gates, revision)
             VALUES (1, ?1, ?2, ?3, 1)",
            rusqlite::params![state.schema_version, state.seeded_from, gates_json],
        )?;
        return if inserted == 1 {
            Ok(ExclusiveWriteResult::Applied { revision: 1 })
        } else {
            Ok(ExclusiveWriteResult::RevisionConflict {
                current_revision: current_revision(conn)?,
            })
        };
    }

    let updated = conn.execute(
        "UPDATE execution_gates
         SET schema_version = ?1,
             seeded_from = ?2,
             gates = ?3,
             revision = revision + 1
         WHERE id = 1 AND revision = ?4",
        rusqlite::params![
            state.schema_version,
            state.seeded_from,
            gates_json,
            expected_revision
        ],
    )?;
    if updated == 1 {
        Ok(ExclusiveWriteResult::Applied {
            revision: expected_revision.saturating_add(1).max(1),
        })
    } else {
        Ok(ExclusiveWriteResult::RevisionConflict {
            current_revision: current_revision(conn)?,
        })
    }
}

pub(crate) fn delete(conn: &Connection) -> Result<()> {
    conn.execute("DELETE FROM execution_gates WHERE id = 1", [])?;
    Ok(())
}

pub(crate) fn delete_if_revision_matches(
    conn: &Connection,
    expected_revision: u64,
) -> Result<ExclusiveDeleteResult> {
    if expected_revision == 0 {
        let current_revision = current_revision(conn)?;
        return Ok(if current_revision == 0 {
            ExclusiveDeleteResult::IdempotentNoop
        } else {
            ExclusiveDeleteResult::RevisionConflict { current_revision }
        });
    }

    let deleted = conn.execute(
        "DELETE FROM execution_gates WHERE id = 1 AND revision = ?1",
        rusqlite::params![expected_revision],
    )?;
    if deleted == 1 {
        Ok(ExclusiveDeleteResult::Applied)
    } else {
        Ok(ExclusiveDeleteResult::RevisionConflict {
            current_revision: current_revision(conn)?,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::schema;
    use crate::state::session_gates::{ExecutionGate, ExecutionGateStatus};

    fn test_conn() -> Connection {
        let conn = Connection::open_in_memory().unwrap();
        schema::initialize(&conn, None).unwrap();
        conn
    }

    #[test]
    fn read_returns_none_when_empty() {
        let conn = test_conn();
        assert!(read(&conn).unwrap().is_none());
    }

    #[test]
    fn write_and_read_roundtrip() {
        let conn = test_conn();
        let state = ExecutionGateStateFile {
            schema_version: 1,
            seeded_from: Some("handoff:immediate_actions".to_owned()),
            revision: 0,
            gates: vec![
                ExecutionGate {
                    text: "First".to_owned(),
                    status: ExecutionGateStatus::Open,
                },
                ExecutionGate {
                    text: "Second".to_owned(),
                    status: ExecutionGateStatus::Blocked,
                },
            ],
        };

        let revision = write(&conn, &state).unwrap();
        assert_eq!(revision, 1);
        let loaded = read(&conn).unwrap().expect("execution gates");
        assert_eq!(
            loaded.seeded_from.as_deref(),
            Some("handoff:immediate_actions")
        );
        assert_eq!(loaded.revision, 1);
        assert_eq!(loaded.gates.len(), 2);
        assert_eq!(loaded.gates[1].status, ExecutionGateStatus::Blocked);
    }

    #[test]
    fn delete_clears_execution_gates() {
        let conn = test_conn();
        let state = ExecutionGateStateFile {
            schema_version: 1,
            seeded_from: None,
            revision: 0,
            gates: vec![ExecutionGate {
                text: "Only".to_owned(),
                status: ExecutionGateStatus::Open,
            }],
        };
        write(&conn, &state).unwrap();
        delete(&conn).unwrap();
        assert!(read(&conn).unwrap().is_none());
    }

    #[test]
    fn write_if_revision_matches_detects_conflicts() {
        let conn = test_conn();
        let state = ExecutionGateStateFile {
            schema_version: 1,
            seeded_from: None,
            revision: 0,
            gates: vec![ExecutionGate {
                text: "Only".to_owned(),
                status: ExecutionGateStatus::Open,
            }],
        };

        let first = write_if_revision_matches(&conn, &state, 0).unwrap();
        assert_eq!(first, ExclusiveWriteResult::Applied { revision: 1 });

        let conflict = write_if_revision_matches(&conn, &state, 0).unwrap();
        assert_eq!(
            conflict,
            ExclusiveWriteResult::RevisionConflict {
                current_revision: 1
            }
        );

        let zero_conflict = delete_if_revision_matches(&conn, 0).unwrap();
        assert_eq!(
            zero_conflict,
            ExclusiveDeleteResult::RevisionConflict {
                current_revision: 1
            }
        );

        let deleted = delete_if_revision_matches(&conn, 1).unwrap();
        assert_eq!(deleted, ExclusiveDeleteResult::Applied);
        let noop = delete_if_revision_matches(&conn, 0).unwrap();
        assert_eq!(noop, ExclusiveDeleteResult::IdempotentNoop);
    }
}