systemprompt-extension 0.58.0

Compile-time extension framework for systemprompt.io AI governance infrastructure. Built on the inventory crate — registers schemas, API routes, jobs, and providers in the MCP governance pipeline.
Documentation
//! Build-script support for extension crates.
//!
//! [`emit_migrations`] discovers an extension crate's
//! `schema/migrations/NNN_<name>.sql` files and writes the body of
//! [`Extension::migrations`](crate::Extension) to `OUT_DIR` for
//! [`extension_migrations!`](crate::extension_migrations). Filenames define
//! versions and names, while `cargo:rerun-if-changed` tracks additions.
//!
//! # Conventions
//!
//! - `NNN_<name>.sql` — an up migration; `NNN` parses to the version, the
//!   remainder is the name.
//! - `NNN_<name>.down.sql` — the paired down migration (optional).
//! - A migration whose first non-blank line is `-- @no-transaction` is emitted
//!   with [`Migration::new_no_transaction`](crate::Migration::new_no_transaction).
//! - Each leading `-- @supersedes-checksum: <16 hex>` names replaced migration
//!   text. Its tracking row moves to the new checksum without rerunning SQL.
//!   This corrects text only when the old and new forms produce the same state;
//!   changes that must execute on established databases require a new
//!   migration.
//! - `NNN_<name>.tombstone` / `NNN-MMM_<name>.tombstone` — a spent slot. The
//!   migration once lived here, shipped, and its file has since been deleted;
//!   established databases still carry its tracking row. A tombstone declares
//!   the number so it can never be refilled, and its body is prose, never SQL.
//!
//! Tombstones make `ls` authoritative: `reject_duplicate_versions` treats their
//! ranges as occupied, so refilling a spent slot fails the build.
//!
//! [`emit_migrations`] panics when invoked outside a build script, when a file
//! in the migrations directory is not named `NNN_<name>.sql` or
//! `NNN[-MMM]_<name>.tombstone`, or when two files claim the same version: a
//! panic is the only way a build script aborts the build.

use std::path::{Path, PathBuf};

pub fn emit_migrations() {
    let manifest = required_env("CARGO_MANIFEST_DIR");
    let dir = Path::new(&manifest).join("schema/migrations");
    println!("cargo:rerun-if-changed={}", dir.display());

    let mut migrations = discover(&dir);
    migrations.sort_by_key(|m| m.version);
    reject_duplicate_versions(&migrations);

    let mut body = String::from("vec![\n");
    for migration in &migrations {
        body.push_str(&migration.render());
    }
    body.push_str("]\n");

    let out = PathBuf::from(required_env("OUT_DIR")).join("migrations.rs");
    if let Err(e) = std::fs::write(&out, body) {
        panic!("failed to write {}: {e}", out.display());
    }
}

struct DiscoveredMigration {
    version: u32,
    end_version: u32,
    name: String,
    up_path: Option<PathBuf>,
    down_path: Option<PathBuf>,
    no_transaction: bool,
    supersedes: Vec<String>,
}

impl DiscoveredMigration {
    fn render(&self) -> String {
        let Some(up_path) = self.up_path.as_ref() else {
            return self.render_tombstone();
        };
        let up = path_literal(up_path);
        let mut supersedes = String::new();
        for old in &self.supersedes {
            supersedes.push_str(&format!(".superseding({old:?})"));
        }
        match (&self.down_path, self.no_transaction) {
            (Some(_), true) => panic!(
                "migration {:03} ({}): a `-- @no-transaction` migration cannot declare a \
                 `.down.sql` — down migrations run inside a transaction",
                self.version, self.name
            ),
            (Some(down), false) => format!(
                "    ::systemprompt_extension::Migration::with_down({}, {:?}, include_str!({up}), \
                 include_str!({})){supersedes},\n",
                self.version,
                self.name,
                path_literal(down),
            ),
            (None, true) => format!(
                "    ::systemprompt_extension::Migration::new_no_transaction({}, {:?}, \
                 include_str!({up})){supersedes},\n",
                self.version, self.name,
            ),
            (None, false) => format!(
                "    ::systemprompt_extension::Migration::new({}, {:?}, \
                 include_str!({up})){supersedes},\n",
                self.version, self.name,
            ),
        }
    }

    // Why: one entry per covered version — the runner reasons about single
    // versions, so a range is expanded here rather than understood downstream.
    fn render_tombstone(&self) -> String {
        assert!(
            self.down_path.is_none(),
            "tombstone {:03} ({}) cannot declare a `.down.sql` — a spent slot has no SQL to \
             revert",
            self.version,
            self.name
        );
        (self.version..=self.end_version).fold(String::new(), |mut out, version| {
            let name = &self.name;
            out.push_str(&format!(
                "    ::systemprompt_extension::Migration::tombstone({version}, {name:?}),\n"
            ));
            out
        })
    }
}

fn discover(dir: &Path) -> Vec<DiscoveredMigration> {
    if !dir.is_dir() {
        return Vec::new();
    }

    let mut ups: Vec<PathBuf> = Vec::new();
    let mut tombstones: Vec<PathBuf> = Vec::new();
    let mut downs: std::collections::HashMap<String, PathBuf> = std::collections::HashMap::new();

    let entries = std::fs::read_dir(dir)
        .unwrap_or_else(|e| panic!("failed to read migrations directory {}: {e}", dir.display()));
    for entry in entries {
        let path = entry
            .unwrap_or_else(|e| panic!("failed to read entry in {}: {e}", dir.display()))
            .path();
        match path.extension().and_then(|e| e.to_str()) {
            Some("tombstone") => tombstones.push(path),
            Some("sql") => {
                let stem = file_stem(&path);
                if let Some(base) = stem.strip_suffix(".down") {
                    downs.insert(base.to_owned(), path);
                } else {
                    ups.push(path);
                }
            },
            _ => {},
        }
    }

    let mut migrations: Vec<DiscoveredMigration> = ups
        .iter()
        .map(|up| {
            let stem = file_stem(up);
            let (version, end_version, name) = parse_stem(&stem, up);
            assert!(
                version == end_version,
                "migration file {} may not name a version range — only a `.tombstone` covers \
                 more than one slot",
                up.display()
            );
            DiscoveredMigration {
                version,
                end_version,
                name,
                down_path: downs.remove(&stem),
                no_transaction: has_no_transaction_directive(up),
                supersedes: supersedes_directive(up),
                up_path: Some(up.clone()),
            }
        })
        .collect();

    if let Some((orphan_stem, orphan_path)) = downs.into_iter().next() {
        panic!(
            "down migration {} has no matching up migration {orphan_stem}.sql",
            orphan_path.display()
        );
    }

    migrations.extend(tombstones.iter().map(|path| {
        let stem = file_stem(path);
        let (version, end_version, name) = parse_stem(&stem, path);
        DiscoveredMigration {
            version,
            end_version,
            name,
            down_path: None,
            no_transaction: false,
            supersedes: Vec::new(),
            up_path: None,
        }
    }));

    migrations
}

fn file_stem(path: &Path) -> String {
    path.file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or_else(|| panic!("migration path {} has no usable file stem", path.display()))
        .to_owned()
}

// Why: returns an inclusive version span. Everything but a `.tombstone` is
// asserted by the caller to span exactly one version.
fn parse_stem(stem: &str, path: &Path) -> (u32, u32, String) {
    let (prefix, name) = stem.split_once('_').unwrap_or_else(|| {
        panic!(
            "migration file {} must be named NNN_<name>.sql or NNN[-MMM]_<name>.tombstone",
            path.display()
        )
    });
    let (start_text, end_text) = prefix.split_once('-').unwrap_or((prefix, prefix));
    let start = parse_version(start_text, prefix, path);
    let end = parse_version(end_text, prefix, path);
    assert!(
        start <= end,
        "migration file {} names a descending version range `{prefix}`",
        path.display()
    );
    (start, end, name.to_owned())
}

fn parse_version(text: &str, prefix: &str, path: &Path) -> u32 {
    text.parse::<u32>().unwrap_or_else(|_| {
        panic!(
            "migration file {} has a non-numeric version prefix `{prefix}`",
            path.display()
        )
    })
}

// Why: the directive must sit in the leading comment block, before any SQL,
// so it is not confused with a statement-level comment.
fn supersedes_directive(path: &Path) -> Vec<String> {
    let content = std::fs::read_to_string(path)
        .unwrap_or_else(|e| panic!("failed to read migration {}: {e}", path.display()));
    content
        .lines()
        .map(str::trim)
        .take_while(|line| line.is_empty() || line.starts_with("--"))
        .filter_map(|line| line.strip_prefix("-- @supersedes-checksum:"))
        .map(str::trim)
        .map(|old| {
            assert!(
                old.len() == 16 && old.chars().all(|c| c.is_ascii_hexdigit()),
                "migration {}: `@supersedes-checksum` must name a 16-hex-digit checksum, got \
                 `{old}`",
                path.display()
            );
            old.to_owned()
        })
        .collect()
}

fn has_no_transaction_directive(path: &Path) -> bool {
    let content = std::fs::read_to_string(path)
        .unwrap_or_else(|e| panic!("failed to read migration {}: {e}", path.display()));
    content
        .lines()
        .map(str::trim)
        .find(|line| !line.is_empty())
        .is_some_and(|line| line == "-- @no-transaction")
}

// Why: the spans are sorted by start, so an overlap can only be with the
// immediate predecessor's high-water end. A tombstoned span counts as occupied,
// which is what turns refilling a spent slot into a build failure.
fn reject_duplicate_versions(migrations: &[DiscoveredMigration]) {
    let mut highest: Option<&DiscoveredMigration> = None;
    for migration in migrations {
        if let Some(previous) = highest
            && previous.end_version >= migration.version
        {
            panic!(
                "two migration files claim version {:03}: `{}` and `{}` — if one of them is a \
                 tombstone the slot is already spent, so take the next free number",
                migration.version, previous.name, migration.name
            );
        }
        if highest.is_none_or(|p| migration.end_version > p.end_version) {
            highest = Some(migration);
        }
    }
}

fn path_literal(path: &Path) -> String {
    let text = path
        .to_str()
        .unwrap_or_else(|| panic!("migration path {} is not valid UTF-8", path.display()));
    format!("{text:?}")
}

fn required_env(key: &str) -> String {
    std::env::var(key).unwrap_or_else(|_| {
        panic!("{key} is not set; this function must be called from a build script")
    })
}