use sha2::{Digest, Sha256};
use std::{
env,
fmt::Write,
fs,
io::{self, Write as IoWrite},
path::{Path, PathBuf},
process::Command,
};
const TARGETS: &[TargetSpec] = &[
TargetSpec {
const_name: "OPENCODE_EMBEDDED_ASSETS",
relative_root: "assets/generated/config/opencode",
},
TargetSpec {
const_name: "CLAUDE_EMBEDDED_ASSETS",
relative_root: "assets/generated/config/claude",
},
TargetSpec {
const_name: "HOOK_EMBEDDED_ASSETS",
relative_root: "assets/hooks",
},
];
const MIGRATIONS_ROOT: &str = "migrations";
const GENERATED_MIGRATIONS_PATH: &str = "src/generated_migrations.rs";
struct TargetSpec {
const_name: &'static str,
relative_root: &'static str,
}
fn main() {
if let Err(error) = generate_embedded_asset_manifest() {
panic!("failed to generate setup embedded asset manifest: {error}");
}
if let Err(error) = generate_migration_manifest() {
panic!("failed to generate embedded migration manifest: {error}");
}
emit_git_commit();
emit_repo_version();
}
fn generate_migration_manifest() -> io::Result<()> {
println!("cargo:rerun-if-changed=build.rs");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|e| invalid_data(&e))?);
let migrations_root = manifest_dir.join(MIGRATIONS_ROOT);
let destination_path = manifest_dir.join(GENERATED_MIGRATIONS_PATH);
println!("cargo:rerun-if-changed={}", migrations_root.display());
let mut databases = collect_migration_databases(&migrations_root)?;
databases.sort_unstable_by(|a, b| a.directory_name.cmp(&b.directory_name));
let mut output = String::new();
output.push_str("// @generated by build.rs; do not edit by hand.\n");
output.push_str("#![allow(dead_code)]\n\n");
for database in &databases {
output.push_str("#[rustfmt::skip]\n");
output.push_str("pub static ");
output.push_str(&database.const_name);
output.push_str(": &[(&str, &str)] = &[\n");
for migration in &database.migrations {
println!(
"cargo:rerun-if-changed={}",
migration.absolute_path.display()
);
writeln!(
output,
" (\"{}\", include_str!(\"{}\")),",
escape_for_rust_string(&migration.id),
escape_for_rust_string(&migration.include_path),
)
.expect("writing to String buffer should never fail");
}
output.push_str("];\n\n");
}
let trimmed = format!("{}\n", output.trim_end());
write_if_changed(&destination_path, trimmed.as_bytes())
}
fn emit_git_commit() {
println!("cargo:rerun-if-env-changed=SCE_GIT_COMMIT");
if let Ok(commit) = env::var("SCE_GIT_COMMIT") {
let commit = commit.trim();
if !commit.is_empty() {
println!("cargo:rustc-env=SCE_GIT_COMMIT={commit}");
return;
}
}
let manifest_dir = match env::var("CARGO_MANIFEST_DIR") {
Ok(value) => PathBuf::from(value),
Err(_) => return,
};
let repository_root = match manifest_dir.parent() {
Some(path) => path.to_path_buf(),
None => return,
};
let git_dir = repository_root.join(".git");
println!("cargo:rerun-if-changed={}", git_dir.join("HEAD").display());
println!(
"cargo:rerun-if-changed={}",
git_dir.join("packed-refs").display()
);
let output = Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"])
.current_dir(&repository_root)
.output();
let Ok(output) = output else {
return;
};
if !output.status.success() {
return;
}
let Ok(commit) = String::from_utf8(output.stdout) else {
return;
};
let commit = commit.trim();
if !commit.is_empty() {
println!("cargo:rustc-env=SCE_GIT_COMMIT={commit}");
}
}
fn emit_repo_version() {
let manifest_dir = match env::var("CARGO_MANIFEST_DIR") {
Ok(value) => PathBuf::from(value),
Err(_) => return,
};
let repository_root = match manifest_dir.parent() {
Some(path) => path.to_path_buf(),
None => return,
};
let version_path = repository_root.join(".version");
println!("cargo:rerun-if-changed={}", version_path.display());
let Ok(bytes) = fs::read(&version_path) else {
return;
};
let version = String::from_utf8_lossy(&bytes);
let version = version.trim();
if !version.is_empty() {
println!("cargo:rustc-env=SCE_VERSION={version}");
}
}
fn generate_embedded_asset_manifest() -> io::Result<()> {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|e| invalid_data(&e))?);
let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|e| invalid_data(&e))?);
let destination_path = out_dir.join("setup_embedded_assets.rs");
let mut output = String::new();
for target in TARGETS {
let source_root = manifest_dir.join(target.relative_root);
println!("cargo:rerun-if-changed={}", source_root.display());
let mut files = Vec::new();
collect_files(&source_root, &source_root, &mut files)?;
files.sort_unstable_by(|a, b| a.relative_path.cmp(&b.relative_path));
writeln!(
output,
"pub static {}: &[EmbeddedAsset] = &[",
target.const_name
)
.expect("writing to String buffer should never fail");
for file in &files {
println!("cargo:rerun-if-changed={}", file.absolute_path.display());
let bytes = fs::read(&file.absolute_path)?;
let sha256 = compute_sha256(&bytes);
writeln!(
output,
" EmbeddedAsset {{ relative_path: \"{}\", bytes: {}, sha256: {} }},",
escape_for_rust_string(&file.relative_path),
format_byte_literal("&[", &bytes),
format_byte_literal("[", &sha256),
)
.expect("writing to String buffer should never fail");
}
output.push_str("];\n\n");
}
let mut output_file = fs::File::create(destination_path)?;
output_file.write_all(output.as_bytes())
}
#[derive(Debug)]
struct MigrationDatabase {
directory_name: String,
const_name: String,
migrations: Vec<MigrationFile>,
}
#[derive(Debug)]
struct MigrationFile {
absolute_path: PathBuf,
id: String,
include_path: String,
sort_prefix: u64,
}
fn collect_migration_databases(migrations_root: &Path) -> io::Result<Vec<MigrationDatabase>> {
let mut databases = Vec::new();
for entry in fs::read_dir(migrations_root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let directory_path = entry.path();
let directory_name = entry
.file_name()
.to_str()
.ok_or_else(|| invalid_data(&"non-UTF-8 migration directory names are not supported"))?
.to_owned();
println!("cargo:rerun-if-changed={}", directory_path.display());
let mut migrations = collect_migration_files(&directory_name, &directory_path)?;
migrations.sort_unstable_by(|a, b| {
a.sort_prefix
.cmp(&b.sort_prefix)
.then_with(|| a.id.cmp(&b.id))
});
databases.push(MigrationDatabase {
const_name: migration_const_name(&directory_name)?,
directory_name,
migrations,
});
}
Ok(databases)
}
fn collect_migration_files(
directory_name: &str,
directory_path: &Path,
) -> io::Result<Vec<MigrationFile>> {
let mut migrations = Vec::new();
for entry in fs::read_dir(directory_path)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir()
|| path.extension().and_then(|value| value.to_str()) != Some("sql")
{
continue;
}
let id = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| invalid_data(&"non-UTF-8 migration filenames are not supported"))?
.to_owned();
migrations.push(MigrationFile {
absolute_path: path,
include_path: format!("../{MIGRATIONS_ROOT}/{directory_name}/{id}.sql"),
sort_prefix: migration_sort_prefix(&id)?,
id,
});
}
Ok(migrations)
}
fn migration_sort_prefix(id: &str) -> io::Result<u64> {
let prefix = id
.split_once('_')
.map(|(prefix, _)| prefix)
.ok_or_else(|| invalid_data(&format!("migration filename '{id}' must contain '_'")))?;
if prefix.is_empty() || !prefix.chars().all(|character| character.is_ascii_digit()) {
return Err(invalid_data(&format!(
"migration filename '{id}' must start with a numeric prefix"
)));
}
prefix.parse::<u64>().map_err(|error| invalid_data(&error))
}
fn migration_const_name(directory_name: &str) -> io::Result<String> {
let mut const_name = String::new();
for character in directory_name.chars() {
if character.is_ascii_alphanumeric() {
const_name.push(character.to_ascii_uppercase());
} else if character == '-' || character == '_' {
const_name.push('_');
} else {
return Err(invalid_data(&format!(
"migration directory '{directory_name}' contains unsupported character '{character}'"
)));
}
}
if const_name.is_empty() {
return Err(invalid_data(&"migration directory name cannot be empty"));
}
const_name.push_str("_MIGRATIONS");
Ok(const_name)
}
#[derive(Debug)]
struct SourceFile {
absolute_path: PathBuf,
relative_path: String,
}
fn collect_files(
base_root: &Path,
current_dir: &Path,
output: &mut Vec<SourceFile>,
) -> io::Result<()> {
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
collect_files(base_root, &path, output)?;
continue;
}
let relative_path = path
.strip_prefix(base_root)
.map_err(|_| invalid_data(&"failed to strip source root from file path"))?;
let relative_path = normalize_relative_path(relative_path)?;
output.push(SourceFile {
absolute_path: path,
relative_path,
});
}
Ok(())
}
fn normalize_relative_path(path: &Path) -> io::Result<String> {
let normalized = path
.to_str()
.ok_or_else(|| invalid_data(&"non-UTF-8 config paths are not supported"))?
.replace('\\', "/");
if normalized.is_empty() {
return Err(invalid_data(&"relative path cannot be empty"));
}
if normalized.starts_with('/') {
return Err(invalid_data(&"relative path must not start with '/'"));
}
Ok(normalized)
}
fn escape_for_rust_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn compute_sha256(bytes: &[u8]) -> [u8; 32] {
let digest = Sha256::digest(bytes);
digest.into()
}
fn format_byte_literal(prefix: &str, bytes: &[u8]) -> String {
format!(
"{prefix}{}]",
bytes
.iter()
.map(|byte| format!("0x{byte:02x}"))
.collect::<Vec<_>>()
.join(", ")
)
}
fn write_if_changed(path: &Path, bytes: &[u8]) -> io::Result<()> {
if fs::read(path).is_ok_and(|existing| existing == bytes) {
return Ok(());
}
fs::write(path, bytes)
}
fn invalid_data<E: ToString>(error: &E) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
}