AkurAI Build
Menu

AkurAI-Build

public

Latest change 834088c92ff481a8a0cc0f5825c8fb9524396e7a - Build lean Git-native AkurAI CI/CD by Ólafur Búi Ólafsson

use std::{
    env,
    fs::{self, OpenOptions},
    io::{Read, Write},
    path::{Path, PathBuf},
    sync::{Arc, Mutex, MutexGuard},
    time::Duration,
};

use anyhow::{Context, Result, anyhow, ensure};
use rusqlite::{
    Connection, OpenFlags, TransactionBehavior, config::DbConfig, limits::Limit, params,
};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

const APPLICATION_ID: i64 = 0x414B_5552;
const MIGRATION: &str = include_str!("../migrations/001_init.sql");
const LEDGER: &str = "CREATE TABLE IF NOT EXISTS _migrations (
    version INTEGER PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    sql TEXT NOT NULL,
    applied_at INTEGER NOT NULL DEFAULT (unixepoch())
) STRICT;";

#[derive(Clone)]
pub struct Database(Arc<Mutex<Connection>>);

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Repository {
    pub id: i64,
    pub name: String,
    pub url: String,
    pub default_branch: String,
    pub created_at: i64,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Run {
    pub id: i64,
    pub repository_id: i64,
    pub repository: String,
    pub git_ref: String,
    pub commit_sha: Option<String>,
    pub trigger: String,
    pub status: String,
    pub error: Option<String>,
    pub created_at: i64,
    pub started_at: Option<i64>,
    pub finished_at: Option<i64>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Job {
    pub id: i64,
    pub run_id: i64,
    pub base_name: String,
    pub name: String,
    pub executor: String,
    pub image: Option<String>,
    pub platform: Option<String>,
    pub status: String,
    pub needs: Vec<String>,
    pub spec_json: String,
    pub logs: String,
    pub exit_code: Option<i64>,
    pub environment: Option<String>,
    pub approval_required: bool,
    pub approved_at: Option<i64>,
    pub started_at: Option<i64>,
    pub finished_at: Option<i64>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Artifact {
    pub id: i64,
    pub run_id: i64,
    pub job_id: i64,
    pub name: String,
    pub path: String,
    pub sha256: String,
    pub bytes: i64,
    pub created_at: i64,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Deployment {
    pub id: i64,
    pub run_id: i64,
    pub job_id: i64,
    pub environment: String,
    pub status: String,
    pub artifacts_json: String,
    pub created_at: i64,
    pub finished_at: Option<i64>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RunDetail {
    #[serde(flatten)]
    pub run: Run,
    pub jobs: Vec<Job>,
    pub artifacts: Vec<Artifact>,
    pub deployments: Vec<Deployment>,
}

impl Database {
    pub fn open(path: &Path, key: &str, create: bool) -> Result<Self> {
        validate_secret(key)?;
        prepare_database(path, create)?;
        let flags = if create {
            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE
        } else {
            OpenFlags::SQLITE_OPEN_READ_WRITE
        } | OpenFlags::SQLITE_OPEN_NO_MUTEX;
        let connection = Connection::open_with_flags(path, flags)
            .with_context(|| format!("open database {}", path.display()))?;
        secure_database_file(path)?;
        configure(&connection, key)?;
        Ok(Self(Arc::new(Mutex::new(connection))))
    }

    #[cfg(test)]
    pub fn memory(key: &str) -> Result<Self> {
        validate_secret(key)?;
        let connection = Connection::open_in_memory()?;
        configure(&connection, key)?;
        let database = Self(Arc::new(Mutex::new(connection)));
        database.migrate()?;
        Ok(database)
    }

    pub fn migrate(&self) -> Result<usize> {
        let mut connection = self.connection()?;
        let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
        transaction.execute_batch(LEDGER)?;
        let existing: Option<(String, String)> = transaction
            .query_row(
                "SELECT name, sql FROM _migrations WHERE version = 1",
                [],
                |row| Ok((row.get(0)?, row.get(1)?)),
            )
            .optional()?;
        let applied = if let Some((name, sql)) = existing {
            ensure!(
                name == "initial CI schema" && sql == MIGRATION,
                "migration history differs from this binary"
            );
            0
        } else {
            transaction.execute_batch(MIGRATION)?;
            transaction.execute(
                "INSERT INTO _migrations(version, name, sql) VALUES (1, 'initial CI schema', ?1)",
                [MIGRATION],
            )?;
            1
        };
        transaction.commit()?;
        drop(connection);
        self.validate_schema()?;
        Ok(applied)
    }

    pub fn validate_schema(&self) -> Result<()> {
        let connection = self.connection()?;
        let application_id: i64 =
            connection.pragma_query_value(None, "application_id", |row| row.get(0))?;
        let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?;
        ensure!(
            application_id == APPLICATION_ID && version == 1,
            "database schema is not AkurAI Build v1"
        );
        for table in [
            "repositories",
            "runs",
            "jobs",
            "artifacts",
            "deployments",
            "_migrations",
        ] {
            let exists: bool = connection.query_row(
                "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)",
                [table],
                |row| row.get(0),
            )?;
            ensure!(exists, "database is missing table {table}");
        }
        Ok(())
    }

    pub fn check_ready(&self) -> Result<()> {
        self.connection()?.query_row("SELECT 1", [], |_| Ok(()))?;
        Ok(())
    }

    pub fn add_repository(&self, name: &str, url: &str, branch: &str) -> Result<Repository> {
        let connection = self.connection()?;
        connection.execute(
            "INSERT INTO repositories(name, url, default_branch) VALUES (?1, ?2, ?3)
             ON CONFLICT(name) DO UPDATE SET url=excluded.url, default_branch=excluded.default_branch",
            params![name, url, branch],
        )?;
        drop(connection);
        self.repository(name)
    }

    pub fn repository(&self, name: &str) -> Result<Repository> {
        self.connection()?
            .query_row(
                "SELECT id, name, url, default_branch, created_at FROM repositories WHERE name=?1",
                [name],
                map_repository,
            )
            .with_context(|| format!("unknown repository {name}"))
    }

    pub fn repositories(&self) -> Result<Vec<Repository>> {
        let connection = self.connection()?;
        let mut statement = connection.prepare(
            "SELECT id, name, url, default_branch, created_at FROM repositories ORDER BY name",
        )?;
        Ok(statement
            .query_map([], map_repository)?
            .collect::<rusqlite::Result<_>>()?)
    }

    pub fn create_run(
        &self,
        repository_id: i64,
        git_ref: &str,
        commit: Option<&str>,
        trigger: &str,
    ) -> Result<i64> {
        let connection = self.connection()?;
        connection.execute(
            "INSERT INTO runs(repository_id, git_ref, commit_sha, trigger, status) VALUES (?1, ?2, ?3, ?4, 'queued')",
            params![repository_id, git_ref, commit, trigger],
        )?;
        Ok(connection.last_insert_rowid())
    }

    pub fn run(&self, id: i64) -> Result<Run> {
        self.connection()?
            .query_row(
                "SELECT r.id, r.repository_id, p.name, r.git_ref, r.commit_sha, r.trigger, r.status,
                        r.error, r.created_at, r.started_at, r.finished_at
                 FROM runs r JOIN repositories p ON p.id=r.repository_id WHERE r.id=?1",
                [id],
                map_run,
            )
            .with_context(|| format!("unknown run {id}"))
    }

    pub fn runs(&self, repository: Option<&str>, limit: usize) -> Result<Vec<Run>> {
        let connection = self.connection()?;
        let limit = i64::try_from(limit.min(200))?;
        let sql =
            "SELECT r.id, r.repository_id, p.name, r.git_ref, r.commit_sha, r.trigger, r.status,
                          r.error, r.created_at, r.started_at, r.finished_at
                   FROM runs r JOIN repositories p ON p.id=r.repository_id
                   WHERE (?1 IS NULL OR p.name=?1) ORDER BY r.id DESC LIMIT ?2";
        let mut statement = connection.prepare(sql)?;
        Ok(statement
            .query_map(params![repository, limit], map_run)?
            .collect::<rusqlite::Result<_>>()?)
    }

    pub fn detail(&self, id: i64) -> Result<RunDetail> {
        Ok(RunDetail {
            run: self.run(id)?,
            jobs: self.jobs(id)?,
            artifacts: self.artifacts(id)?,
            deployments: self.deployments(id)?,
        })
    }

    pub fn next_queued_run(&self) -> Result<Option<i64>> {
        Ok(self
            .connection()?
            .query_row(
                "SELECT id FROM runs WHERE status='queued' ORDER BY id LIMIT 1",
                [],
                |row| row.get(0),
            )
            .optional()?)
    }

    pub fn claim_run(&self, id: i64) -> Result<bool> {
        Ok(self.connection()?.execute(
            "UPDATE runs SET status='running', started_at=COALESCE(started_at, unixepoch()), error=NULL WHERE id=?1 AND status='queued'",
            [id],
        )? == 1)
    }

    pub fn set_run_commit(&self, id: i64, commit: &str) -> Result<()> {
        self.connection()?.execute(
            "UPDATE runs SET commit_sha=?2 WHERE id=?1",
            params![id, commit],
        )?;
        Ok(())
    }

    pub fn finish_run(&self, id: i64, status: &str, error: Option<&str>) -> Result<()> {
        self.connection()?.execute(
            "UPDATE runs SET status=?2, error=?3, finished_at=CASE WHEN ?2 IN ('succeeded','failed','canceled','interrupted') THEN unixepoch() ELSE NULL END WHERE id=?1",
            params![id, status, error],
        )?;
        Ok(())
    }

    pub fn insert_jobs(&self, run_id: i64, specs: &[crate::config::JobSpec]) -> Result<()> {
        let mut connection = self.connection()?;
        let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
        for spec in specs {
            let spec_json = serde_json::to_string(spec)?;
            let needs = serde_json::to_string(&spec.needs)?;
            let platform = spec.matrix.get("platform");
            transaction.execute(
                "INSERT INTO jobs(run_id, base_name, name, executor, image, platform, status, needs_json, spec_json, environment, approval_required)
                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'queued', ?7, ?8, ?9, ?10)",
                params![run_id, spec.base_name, spec.name, spec.executor, spec.image, platform, needs, spec_json, spec.environment, spec.approval],
            )?;
        }
        transaction.commit()?;
        Ok(())
    }

    pub fn jobs(&self, run_id: i64) -> Result<Vec<Job>> {
        let connection = self.connection()?;
        let mut statement = connection.prepare(
            "SELECT id, run_id, base_name, name, executor, image, platform, status, needs_json,
                    spec_json, logs, exit_code, environment, approval_required, approved_at, started_at, finished_at
             FROM jobs WHERE run_id=?1 ORDER BY id",
        )?;
        Ok(statement
            .query_map([run_id], map_job)?
            .collect::<rusqlite::Result<_>>()?)
    }

    pub fn set_job_status(
        &self,
        id: i64,
        status: &str,
        logs: Option<&str>,
        exit_code: Option<i32>,
    ) -> Result<()> {
        self.connection()?.execute(
            "UPDATE jobs SET status=?2, logs=COALESCE(?3, logs), exit_code=?4,
                    started_at=CASE WHEN ?2='running' THEN COALESCE(started_at, unixepoch()) ELSE started_at END,
                    finished_at=CASE WHEN ?2 IN ('succeeded','failed','skipped','canceled','interrupted') THEN unixepoch() ELSE finished_at END
             WHERE id=?1",
            params![id, status, logs, exit_code],
        )?;
        Ok(())
    }

    pub fn approve_environment(&self, run_id: i64, environment: &str) -> Result<usize> {
        let connection = self.connection()?;
        let changed = connection.execute(
            "UPDATE jobs SET approved_at=unixepoch(), status=CASE WHEN status='waiting' THEN 'queued' ELSE status END
             WHERE run_id=?1 AND environment=?2 AND approval_required=1 AND status IN ('waiting','queued')",
            params![run_id, environment],
        )?;
        ensure!(
            changed > 0,
            "no promotable {environment} job in run {run_id}"
        );
        connection.execute(
            "UPDATE runs SET status='queued', finished_at=NULL WHERE id=?1 AND status='waiting'",
            [run_id],
        )?;
        Ok(changed)
    }

    pub fn retry(&self, run_id: i64) -> Result<i64> {
        let run = self.run(run_id)?;
        self.create_run(
            run.repository_id,
            &run.git_ref,
            run.commit_sha.as_deref(),
            "retry",
        )
    }

    pub fn add_artifact(
        &self,
        run_id: i64,
        job_id: i64,
        name: &str,
        path: &str,
        sha256: &str,
        bytes: u64,
    ) -> Result<i64> {
        let connection = self.connection()?;
        connection.execute(
            "INSERT INTO artifacts(run_id, job_id, name, path, sha256, bytes) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
            params![run_id, job_id, name, path, sha256, i64::try_from(bytes)?],
        )?;
        Ok(connection.last_insert_rowid())
    }

    pub fn artifact(&self, id: i64) -> Result<Artifact> {
        self.connection()?.query_row(
            "SELECT id, run_id, job_id, name, path, sha256, bytes, created_at FROM artifacts WHERE id=?1",
            [id],
            map_artifact,
        ).with_context(|| format!("unknown artifact {id}"))
    }

    pub fn artifacts(&self, run_id: i64) -> Result<Vec<Artifact>> {
        let connection = self.connection()?;
        let mut statement = connection.prepare(
            "SELECT id, run_id, job_id, name, path, sha256, bytes, created_at FROM artifacts WHERE run_id=?1 ORDER BY id",
        )?;
        Ok(statement
            .query_map([run_id], map_artifact)?
            .collect::<rusqlite::Result<_>>()?)
    }

    pub fn begin_deployment(
        &self,
        run_id: i64,
        job_id: i64,
        environment: &str,
        artifacts_json: &str,
    ) -> Result<i64> {
        let connection = self.connection()?;
        connection.execute(
            "INSERT INTO deployments(run_id, job_id, environment, status, artifacts_json) VALUES (?1, ?2, ?3, 'running', ?4)",
            params![run_id, job_id, environment, artifacts_json],
        )?;
        Ok(connection.last_insert_rowid())
    }

    pub fn finish_deployment(&self, id: i64, status: &str) -> Result<()> {
        self.connection()?.execute(
            "UPDATE deployments SET status=?2, finished_at=unixepoch() WHERE id=?1",
            params![id, status],
        )?;
        Ok(())
    }

    pub fn deployments(&self, run_id: i64) -> Result<Vec<Deployment>> {
        let connection = self.connection()?;
        let mut statement = connection.prepare(
            "SELECT id, run_id, job_id, environment, status, artifacts_json, created_at, finished_at
             FROM deployments WHERE run_id=?1 ORDER BY id",
        )?;
        Ok(statement
            .query_map([run_id], map_deployment)?
            .collect::<rusqlite::Result<_>>()?)
    }

    pub fn recover_interrupted(&self) -> Result<usize> {
        let connection = self.connection()?;
        let deployments = connection.execute(
            "UPDATE deployments SET status='failed', finished_at=unixepoch() WHERE status='running'",
            [],
        )?;
        let jobs = connection.execute(
            "UPDATE jobs SET status='interrupted', finished_at=unixepoch() WHERE status='running'",
            [],
        )?;
        let runs = connection.execute(
            "UPDATE runs SET status='interrupted', error='controller restarted during execution', finished_at=unixepoch() WHERE status='running'",
            [],
        )?;
        Ok(deployments + jobs + runs)
    }

    fn connection(&self) -> Result<MutexGuard<'_, Connection>> {
        self.0
            .lock()
            .map_err(|_| anyhow!("database mutex poisoned"))
    }
}

fn map_repository(row: &rusqlite::Row<'_>) -> rusqlite::Result<Repository> {
    Ok(Repository {
        id: row.get(0)?,
        name: row.get(1)?,
        url: row.get(2)?,
        default_branch: row.get(3)?,
        created_at: row.get(4)?,
    })
}

fn map_run(row: &rusqlite::Row<'_>) -> rusqlite::Result<Run> {
    Ok(Run {
        id: row.get(0)?,
        repository_id: row.get(1)?,
        repository: row.get(2)?,
        git_ref: row.get(3)?,
        commit_sha: row.get(4)?,
        trigger: row.get(5)?,
        status: row.get(6)?,
        error: row.get(7)?,
        created_at: row.get(8)?,
        started_at: row.get(9)?,
        finished_at: row.get(10)?,
    })
}

fn map_job(row: &rusqlite::Row<'_>) -> rusqlite::Result<Job> {
    let needs_json: String = row.get(8)?;
    let needs = serde_json::from_str(&needs_json).map_err(|error| {
        rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(error))
    })?;
    Ok(Job {
        id: row.get(0)?,
        run_id: row.get(1)?,
        base_name: row.get(2)?,
        name: row.get(3)?,
        executor: row.get(4)?,
        image: row.get(5)?,
        platform: row.get(6)?,
        status: row.get(7)?,
        needs,
        spec_json: row.get(9)?,
        logs: row.get(10)?,
        exit_code: row.get(11)?,
        environment: row.get(12)?,
        approval_required: row.get(13)?,
        approved_at: row.get(14)?,
        started_at: row.get(15)?,
        finished_at: row.get(16)?,
    })
}

fn map_artifact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Artifact> {
    Ok(Artifact {
        id: row.get(0)?,
        run_id: row.get(1)?,
        job_id: row.get(2)?,
        name: row.get(3)?,
        path: row.get(4)?,
        sha256: row.get(5)?,
        bytes: row.get(6)?,
        created_at: row.get(7)?,
    })
}

fn map_deployment(row: &rusqlite::Row<'_>) -> rusqlite::Result<Deployment> {
    Ok(Deployment {
        id: row.get(0)?,
        run_id: row.get(1)?,
        job_id: row.get(2)?,
        environment: row.get(3)?,
        status: row.get(4)?,
        artifacts_json: row.get(5)?,
        created_at: row.get(6)?,
        finished_at: row.get(7)?,
    })
}

fn prepare_database(path: &Path, create: bool) -> Result<()> {
    let parent = path.parent().context("database path has no parent")?;
    if create {
        fs::create_dir_all(parent)?;
    }
    ensure!(
        parent.is_dir(),
        "database parent does not exist: {}",
        parent.display()
    );
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
    }
    match fs::symlink_metadata(path) {
        Ok(metadata) => ensure!(
            metadata.is_file() && !metadata.file_type().is_symlink(),
            "database must be a regular non-symlink file"
        ),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound && create => {}
        Err(error) => return Err(error.into()),
    }
    Ok(())
}

fn secure_database_file(path: &Path) -> Result<()> {
    let metadata = fs::symlink_metadata(path)?;
    ensure!(
        metadata.is_file() && !metadata.file_type().is_symlink(),
        "database must be a regular non-symlink file"
    );
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
    }
    Ok(())
}

pub fn load_secret(path: &Path, environment: &str) -> Result<Zeroizing<String>> {
    let mut value = Zeroizing::new(String::new());
    match fs::symlink_metadata(path) {
        Ok(metadata) => {
            ensure!(
                metadata.is_file() && !metadata.file_type().is_symlink(),
                "secret must be a regular non-symlink file"
            );
            #[cfg(unix)]
            {
                use std::os::unix::fs::MetadataExt;
                ensure!(
                    metadata.mode() & 0o077 == 0,
                    "secret file permissions must be 0600"
                );
            }
            ensure!(metadata.len() <= 1024, "secret file is too large");
            let mut file = crate::fsguard::open_nofollow(path, false)?;
            file.read_to_string(&mut value)?;
            crate::fsguard::ensure_path_matches_file(path, &file, "secret")?;
            let trimmed = value.trim_end().len();
            value.truncate(trimmed);
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            value.push_str(
                &env::var(environment)
                    .with_context(|| format!("missing {environment} or {}", path.display()))?,
            );
        }
        Err(error) => return Err(error.into()),
    }
    validate_secret(&value)?;
    Ok(value)
}

pub fn generate_secret(path: &Path) -> Result<()> {
    ensure!(!path.exists(), "refusing to overwrite {}", path.display());
    let parent = path.parent().context("secret path has no parent")?;
    fs::create_dir_all(parent)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
        let mut file = OpenOptions::new()
            .write(true)
            .create_new(true)
            .mode(0o600)
            .open(path)?;
        write_secret(&mut file)?;
    }
    #[cfg(not(unix))]
    {
        let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
        write_secret(&mut file)?;
    }
    Ok(())
}

fn write_secret(file: &mut fs::File) -> Result<()> {
    let mut bytes = Zeroizing::new([0_u8; 32]);
    getrandom::fill(bytes.as_mut()).context("read operating-system randomness")?;
    let mut encoded = Zeroizing::new(String::with_capacity(65));
    for byte in bytes.iter() {
        use std::fmt::Write as _;
        write!(encoded, "{byte:02x}")?;
    }
    encoded.push('\n');
    file.write_all(encoded.as_bytes())?;
    file.sync_all()?;
    Ok(())
}

fn validate_secret(value: &str) -> Result<()> {
    ensure!(
        value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()),
        "secret must be 32 bytes encoded as hexadecimal"
    );
    Ok(())
}

fn decode_secret(value: &str) -> Result<Zeroizing<Vec<u8>>> {
    validate_secret(value)?;
    let mut bytes = Zeroizing::new(Vec::with_capacity(32));
    for pair in value.as_bytes().chunks_exact(2) {
        let text = std::str::from_utf8(pair)?;
        bytes.push(u8::from_str_radix(text, 16)?);
    }
    Ok(bytes)
}

fn configure(connection: &Connection, key: &str) -> Result<()> {
    apply_key(connection, key)?;
    let cipher: String = connection.pragma_query_value(None, "cipher_version", |row| row.get(0))?;
    ensure!(!cipher.is_empty(), "SQLCipher support is unavailable");
    connection
        .query_row("SELECT count(*) FROM sqlite_schema", [], |_| Ok(()))
        .context("database key is incorrect or database is corrupt")?;
    connection.busy_timeout(Duration::from_secs(5))?;
    connection.pragma_update(None, "foreign_keys", "ON")?;
    connection.pragma_update(None, "journal_mode", "WAL")?;
    connection.pragma_update(None, "synchronous", "FULL")?;
    connection.pragma_update(None, "secure_delete", "ON")?;
    connection.set_db_config(DbConfig::SQLITE_DBCONFIG_DEFENSIVE, true)?;
    connection.set_db_config(DbConfig::SQLITE_DBCONFIG_TRUSTED_SCHEMA, false)?;
    connection.set_limit(Limit::SQLITE_LIMIT_LENGTH, 16 * 1024 * 1024)?;
    connection.set_limit(Limit::SQLITE_LIMIT_SQL_LENGTH, 256 * 1024)?;
    connection.set_limit(Limit::SQLITE_LIMIT_VARIABLE_NUMBER, 128)?;
    Ok(())
}

#[allow(unsafe_code)]
fn apply_key(connection: &Connection, encoded: &str) -> Result<()> {
    let key = decode_secret(encoded)?;
    let length = i32::try_from(key.len())?;
    // SAFETY: SQLCipher copies these bytes during the call; rusqlite owns the live handle.
    let result =
        unsafe { rusqlite::ffi::sqlite3_key(connection.handle(), key.as_ptr().cast(), length) };
    ensure!(
        result == rusqlite::ffi::SQLITE_OK,
        "SQLCipher rejected the database key"
    );
    Ok(())
}

trait OptionalRow<T> {
    fn optional(self) -> rusqlite::Result<Option<T>>;
}
impl<T> OptionalRow<T> for rusqlite::Result<T> {
    fn optional(self) -> rusqlite::Result<Option<T>> {
        match self {
            Ok(value) => Ok(Some(value)),
            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
            Err(error) => Err(error),
        }
    }
}

pub fn default_paths(root: &Path) -> (PathBuf, PathBuf, PathBuf) {
    (
        root.join("akurai.db"),
        root.join("workspaces"),
        root.join("artifacts"),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";

    #[test]
    fn stores_ci_state_and_reopens_details() -> Result<()> {
        let database = Database::memory(KEY)?;
        let repo = database.add_repository("app", "https://example.com/app.git", "main")?;
        let run = database.create_run(repo.id, "main", None, "manual")?;
        assert_eq!(database.detail(run)?.run.status, "queued");
        Ok(())
    }
}