commit 5101e03ab69fd669c70f2c427007511223f84341
parent 71659acf4bb32ddbec8ce0f3ef7706e56b6dc1bd
Author: triesap <tyson@radroots.org>
Date: Thu, 11 Jun 2026 06:09:49 -0700
feat(xtask): add generation command skeleton
Diffstat:
9 files changed, 395 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -679,6 +679,9 @@ dependencies = [
[[package]]
name = "radroots_sdk_xtask"
version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
[[package]]
name = "radroots_secret_vault"
diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml
@@ -11,3 +11,6 @@ publish = false
[[bin]]
name = "radroots_sdk_xtask"
path = "src/main.rs"
+
+[dependencies]
+serde_json = "1"
diff --git a/crates/xtask/src/check.rs b/crates/xtask/src/check.rs
@@ -0,0 +1,71 @@
+use std::{fs, path::Path};
+
+use crate::{
+ fs::workspace_root,
+ package_matrix::{FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix},
+};
+
+pub fn check() -> Result<(), String> {
+ validate_package_matrix()?;
+ let root = workspace_root()?;
+ check_forbidden_packages(&root)?;
+ for spec in package_specs() {
+ let package_dir = root.join(spec.package_dir);
+ let package_json_path = package_dir.join("package.json");
+ let index_path = package_dir.join("src/index.ts");
+ check_package_json(&package_json_path, spec.package_name)?;
+ if !index_path.is_file() {
+ return Err(format!("missing package index: {}", index_path.display()));
+ }
+ }
+ Ok(())
+}
+
+fn check_forbidden_packages(root: &Path) -> Result<(), String> {
+ for forbidden in FORBIDDEN_PACKAGE_NAMES {
+ let package_leaf = forbidden.trim_start_matches("@radroots/").to_owned();
+ let forbidden_dir = root.join("packages").join(package_leaf);
+ if forbidden_dir.exists() {
+ return Err(format!(
+ "forbidden package directory exists: {}",
+ forbidden_dir.display()
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn check_package_json(path: &Path, expected_name: &str) -> Result<(), String> {
+ let raw = fs::read_to_string(path)
+ .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
+ let json = serde_json::from_str::<serde_json::Value>(&raw)
+ .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
+ let actual_name = json
+ .get("name")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| format!("package.json missing name: {}", path.display()))?;
+ if actual_name != expected_name {
+ return Err(format!(
+ "package name mismatch in {}: expected {expected_name}, found {actual_name}",
+ path.display()
+ ));
+ }
+ let private = json
+ .get("private")
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(false);
+ if !private {
+ return Err(format!("package must be private: {}", path.display()));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::check;
+
+ #[test]
+ fn package_skeleton_is_valid() {
+ check().expect("package skeleton validates");
+ }
+}
diff --git a/crates/xtask/src/fs.rs b/crates/xtask/src/fs.rs
@@ -0,0 +1,46 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+};
+
+pub fn workspace_root() -> Result<PathBuf, String> {
+ let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ manifest_dir
+ .parent()
+ .and_then(Path::parent)
+ .map(Path::to_path_buf)
+ .ok_or_else(|| {
+ format!(
+ "cannot resolve workspace root from {}",
+ manifest_dir.display()
+ )
+ })
+}
+
+#[allow(dead_code)]
+pub fn write_if_changed(path: &Path, contents: &str) -> Result<bool, String> {
+ if let Ok(existing) = fs::read_to_string(path) {
+ if existing == contents {
+ return Ok(false);
+ }
+ }
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
+ }
+ fs::write(path, contents)
+ .map_err(|error| format!("failed to write {}: {error}", path.display()))?;
+ Ok(true)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::workspace_root;
+
+ #[test]
+ fn resolves_workspace_root() {
+ let root = workspace_root().expect("workspace root resolves");
+ assert!(root.join("Cargo.toml").is_file());
+ assert!(root.join("packages").is_dir());
+ }
+}
diff --git a/crates/xtask/src/generate.rs b/crates/xtask/src/generate.rs
@@ -0,0 +1,22 @@
+use crate::{
+ manifest::{manifest_file_name, package_manifest},
+ package_matrix::{package_specs, validate_package_matrix},
+ ts::{generated_constants_file, generated_header, generated_types_file, normalize_lf},
+};
+
+pub fn generate_ts() -> Result<(), String> {
+ validate_package_matrix()?;
+ let header = normalize_lf(generated_header());
+ for spec in package_specs() {
+ let manifest = package_manifest(*spec);
+ println!(
+ "planned TypeScript generation for {} with {}, {}, {}, and {}",
+ manifest["package"],
+ generated_types_file(),
+ generated_constants_file(),
+ manifest_file_name(),
+ header.lines().next().unwrap_or_default()
+ );
+ }
+ Ok(())
+}
diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs
@@ -1,3 +1,47 @@
+mod check;
+mod fs;
+mod generate;
+mod manifest;
+mod package_matrix;
+mod ts;
+
fn main() {
- println!("radroots sdk xtask");
+ if let Err(error) = run(std::env::args().skip(1)) {
+ eprintln!("{error}");
+ std::process::exit(1);
+ }
+}
+
+fn run(args: impl IntoIterator<Item = String>) -> Result<(), String> {
+ let args = args.into_iter().collect::<Vec<_>>();
+ match args.as_slice() {
+ [command, target] if command == "generate" && target == "ts" => generate::generate_ts(),
+ [command] if command == "check" => check::check(),
+ [] => Err(usage()),
+ _ => Err(usage()),
+ }
+}
+
+fn usage() -> String {
+ "usage: cargo xtask generate ts | cargo xtask check".to_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::run;
+
+ #[test]
+ fn accepts_generate_ts() {
+ assert!(run(["generate".to_owned(), "ts".to_owned()]).is_ok());
+ }
+
+ #[test]
+ fn accepts_check() {
+ assert!(run(["check".to_owned()]).is_ok());
+ }
+
+ #[test]
+ fn rejects_unknown_command() {
+ assert!(run(["generate".to_owned(), "swift".to_owned()]).is_err());
+ }
}
diff --git a/crates/xtask/src/manifest.rs b/crates/xtask/src/manifest.rs
@@ -0,0 +1,36 @@
+use serde_json::json;
+
+use crate::package_matrix::PackageSpec;
+
+pub fn manifest_file_name() -> &'static str {
+ "sdk-manifest.json"
+}
+
+pub fn package_manifest(spec: PackageSpec) -> serde_json::Value {
+ json!({
+ "package": spec.package_name,
+ "crate": spec.crate_name,
+ "generator": "radroots_sdk_xtask",
+ "generated": false
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::{
+ manifest::{manifest_file_name, package_manifest},
+ package_matrix::package_specs,
+ };
+
+ #[test]
+ fn manifest_name_is_stable() {
+ assert_eq!(manifest_file_name(), "sdk-manifest.json");
+ }
+
+ #[test]
+ fn manifest_records_package_and_crate() {
+ let manifest = package_manifest(package_specs()[0]);
+ assert_eq!(manifest["package"], package_specs()[0].package_name);
+ assert_eq!(manifest["crate"], package_specs()[0].crate_name);
+ }
+}
diff --git a/crates/xtask/src/package_matrix.rs b/crates/xtask/src/package_matrix.rs
@@ -0,0 +1,130 @@
+use std::collections::BTreeSet;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct PackageSpec {
+ pub key: &'static str,
+ pub crate_name: &'static str,
+ pub crate_dir: &'static str,
+ pub package_name: &'static str,
+ pub package_dir: &'static str,
+}
+
+pub const PACKAGE_SPECS: [PackageSpec; 7] = [
+ PackageSpec {
+ key: "core",
+ crate_name: "radroots_core_bindings",
+ crate_dir: "crates/core_bindings",
+ package_name: "@radroots/core-bindings",
+ package_dir: "packages/core-bindings",
+ },
+ PackageSpec {
+ key: "events",
+ crate_name: "radroots_events_bindings",
+ crate_dir: "crates/events_bindings",
+ package_name: "@radroots/events-bindings",
+ package_dir: "packages/events-bindings",
+ },
+ PackageSpec {
+ key: "events_indexed",
+ crate_name: "radroots_events_indexed_bindings",
+ crate_dir: "crates/events_indexed_bindings",
+ package_name: "@radroots/events-indexed-bindings",
+ package_dir: "packages/events-indexed-bindings",
+ },
+ PackageSpec {
+ key: "identity",
+ crate_name: "radroots_identity_bindings",
+ crate_dir: "crates/identity_bindings",
+ package_name: "@radroots/identity-bindings",
+ package_dir: "packages/identity-bindings",
+ },
+ PackageSpec {
+ key: "replica_db_schema",
+ crate_name: "radroots_replica_db_schema_bindings",
+ crate_dir: "crates/replica_db_schema_bindings",
+ package_name: "@radroots/replica-db-schema-bindings",
+ package_dir: "packages/replica-db-schema-bindings",
+ },
+ PackageSpec {
+ key: "trade",
+ crate_name: "radroots_trade_bindings",
+ crate_dir: "crates/trade_bindings",
+ package_name: "@radroots/trade-bindings",
+ package_dir: "packages/trade-bindings",
+ },
+ PackageSpec {
+ key: "types",
+ crate_name: "radroots_types_bindings",
+ crate_dir: "crates/types_bindings",
+ package_name: "@radroots/types-bindings",
+ package_dir: "packages/types-bindings",
+ },
+];
+
+pub const FORBIDDEN_PACKAGE_NAMES: [&str; 2] =
+ ["@radroots/tangle-db-schema-bindings", "@radroots/contracts"];
+
+pub fn package_specs() -> &'static [PackageSpec] {
+ &PACKAGE_SPECS
+}
+
+pub fn validate_package_matrix() -> Result<(), String> {
+ let mut crate_names = BTreeSet::new();
+ let mut package_names = BTreeSet::new();
+ let mut package_dirs = BTreeSet::new();
+ for spec in package_specs() {
+ if FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name) {
+ return Err(format!(
+ "forbidden package in matrix: {}",
+ spec.package_name
+ ));
+ }
+ if !crate_names.insert(spec.crate_name) {
+ return Err(format!("duplicate crate in matrix: {}", spec.crate_name));
+ }
+ if !package_names.insert(spec.package_name) {
+ return Err(format!(
+ "duplicate package in matrix: {}",
+ spec.package_name
+ ));
+ }
+ if !package_dirs.insert(spec.package_dir) {
+ return Err(format!(
+ "duplicate package directory in matrix: {}",
+ spec.package_dir
+ ));
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{FORBIDDEN_PACKAGE_NAMES, package_specs, validate_package_matrix};
+
+ #[test]
+ fn package_matrix_is_valid() {
+ validate_package_matrix().expect("package matrix is valid");
+ }
+
+ #[test]
+ fn approved_package_count_is_stable() {
+ assert_eq!(package_specs().len(), 7);
+ }
+
+ #[test]
+ fn forbidden_names_are_absent() {
+ for spec in package_specs() {
+ assert!(!FORBIDDEN_PACKAGE_NAMES.contains(&spec.package_name));
+ }
+ }
+
+ #[test]
+ fn replica_schema_package_uses_current_name() {
+ assert!(
+ package_specs()
+ .iter()
+ .any(|spec| spec.package_name == "@radroots/replica-db-schema-bindings")
+ );
+ }
+}
diff --git a/crates/xtask/src/ts.rs b/crates/xtask/src/ts.rs
@@ -0,0 +1,39 @@
+pub fn generated_header() -> &'static str {
+ "// @generated by cargo xtask generate ts\n// Do not edit by hand.\n"
+}
+
+pub fn generated_types_file() -> &'static str {
+ "types.ts"
+}
+
+pub fn generated_constants_file() -> &'static str {
+ "constants.ts"
+}
+
+pub fn normalize_lf(value: &str) -> String {
+ value.replace("\r\n", "\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{generated_constants_file, generated_header, generated_types_file, normalize_lf};
+
+ #[test]
+ fn generated_header_matches_contract() {
+ assert_eq!(
+ generated_header(),
+ "// @generated by cargo xtask generate ts\n// Do not edit by hand.\n"
+ );
+ }
+
+ #[test]
+ fn generated_file_names_are_stable() {
+ assert_eq!(generated_types_file(), "types.ts");
+ assert_eq!(generated_constants_file(), "constants.ts");
+ }
+
+ #[test]
+ fn normalizes_line_endings() {
+ assert_eq!(normalize_lf("a\r\nb\n"), "a\nb\n");
+ }
+}