use std::path::{Path, PathBuf};
use std::process::Command;
use chrono::{SecondsFormat, Utc};
use serde_json::json;
use crate::builder::draft::{Draft, Project, FIELD_TYPES};
use crate::builder::history::{append, HistoryOp};
use crate::builder::lifecycle::{
commit as lifecycle_commit, find_project_root, plan as lifecycle_plan, CommitResult,
FileVerdict, LifecycleError, PlanReport,
};
use crate::builder::lockfile::BuilderLock;
use crate::builder::redact::is_secret_field_type;
pub(crate) fn resolve_actor() -> (String, ActorSource) {
if let Some(env) = std::env::var("RUSTIO_AGENT_ID")
.ok()
.filter(|s| !s.is_empty())
{
return (env, ActorSource::AgentEnv);
}
if let Ok(out) = Command::new("git")
.args(["config", "--get", "user.email"])
.output()
{
if out.status.success() {
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !text.is_empty() {
return (text, ActorSource::GitConfig);
}
}
}
("unknown".to_string(), ActorSource::Degraded)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ActorSource {
AgentEnv,
GitConfig,
Degraded,
}
fn warn_if_degraded(source: ActorSource) {
use std::sync::atomic::{AtomicBool, Ordering};
static WARNED: AtomicBool = AtomicBool::new(false);
if source == ActorSource::Degraded && !WARNED.swap(true, Ordering::SeqCst) {
eprintln!(
"warning: could not resolve an actor for history.jsonl. \
Set RUSTIO_AGENT_ID or `git config user.email`. \
Recording events as `actor = \"unknown\"`."
);
}
}
fn plural_snake(name: &str) -> String {
let mut snake = String::new();
for (i, c) in name.chars().enumerate() {
if c.is_ascii_uppercase() && i > 0 {
snake.push('_');
}
snake.push(c.to_ascii_lowercase());
}
if snake.ends_with('s') {
return snake;
}
if snake.ends_with('x')
|| snake.ends_with('z')
|| snake.ends_with("ch")
|| snake.ends_with("sh")
{
return format!("{snake}es");
}
if let Some(stem) = snake.strip_suffix('y') {
let before = stem.chars().last();
if matches!(before, Some('a' | 'e' | 'i' | 'o' | 'u')) || stem.is_empty() {
return format!("{snake}s");
}
return format!("{stem}ies");
}
format!("{snake}s")
}
fn validate_project_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("project name must not be empty".into());
}
if !name.chars().next().unwrap().is_ascii_alphabetic() {
return Err("project name must start with an ASCII letter".into());
}
for c in name.chars() {
let ok = c.is_ascii_alphanumeric() || c == '-' || c == '_';
if !ok {
return Err(format!(
"project name '{name}' contains invalid character {c:?}; only [A-Za-z0-9_-] allowed"
));
}
}
Ok(())
}
fn validate_model_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("model name must not be empty".into());
}
let first = name.chars().next().unwrap();
if !first.is_ascii_uppercase() {
return Err(format!(
"model name '{name}' must be CamelCase and start with an uppercase ASCII letter"
));
}
for c in name.chars() {
if !c.is_ascii_alphanumeric() {
return Err(format!(
"model name '{name}' contains invalid character {c:?}; only ASCII letters and digits allowed"
));
}
}
Ok(())
}
fn validate_field_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("field name must not be empty".into());
}
let first = name.chars().next().unwrap();
if !first.is_ascii_lowercase() {
return Err(format!(
"field name '{name}' must be snake_case and start with a lowercase ASCII letter"
));
}
for c in name.chars() {
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
return Err(format!(
"field name '{name}' contains invalid character {c:?}; only [a-z0-9_] allowed"
));
}
}
if matches!(name, "id" | "created_at") {
return Err(format!(
"field name '{name}' is reserved; the generator emits it implicitly"
));
}
Ok(())
}
pub(crate) fn run_new(name: &str) -> Result<String, String> {
validate_project_name(name)?;
let root = PathBuf::from(name);
if root.exists() {
return Err(format!("'{name}' already exists; refusing to overwrite"));
}
std::fs::create_dir_all(root.join(".rustio")).map_err(|e| e.to_string())?;
std::fs::create_dir_all(root.join("src")).map_err(|e| e.to_string())?;
std::fs::create_dir_all(root.join("migrations")).map_err(|e| e.to_string())?;
let builder_version = env!("CARGO_PKG_VERSION");
let rust_version = env!("CARGO_PKG_RUST_VERSION");
let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
let lock = BuilderLock::current();
std::fs::write(root.join(".rustio/builder.lock"), lock.to_toml()).map_err(|e| e.to_string())?;
let mut draft = Draft::empty();
draft.project = Project {
name: name.to_string(),
rust_version: rust_version.to_string(),
builder_pinned: builder_version.to_string(),
created_at: created_at.clone(),
};
std::fs::write(root.join(".rustio/draft.toml"), draft.to_toml()).map_err(|e| e.to_string())?;
let (actor, source) = resolve_actor();
warn_if_degraded(source);
let history_path = root.join(".rustio/history.jsonl");
append(
&history_path,
HistoryOp::ProjectInit,
&actor,
json!({
"name": name,
"rust_version": rust_version,
"builder_pinned": builder_version,
"created_at": created_at,
}),
)
.map_err(|e| e.to_string())?;
let cargo_toml = format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
rust-version = "{rust_version}"
[dependencies]
rustio-admin = "{builder_version}"
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
chrono = {{ version = "0.4", features = ["serde"] }}
"#
);
std::fs::write(root.join("Cargo.toml"), cargo_toml).map_err(|e| e.to_string())?;
let main_rs = "//! Project entry point. Generator scaffolds this once;\n\
//! thereafter it is developer-owned.\n\
//!\n\
//! Run `rustio-admin add model <Name>` and `rustio-admin commit` to\n\
//! populate `src/_generated/`. Then wire your server here.\n\
\n\
mod _generated;\n\
\n\
#[tokio::main]\n\
async fn main() {\n\
\x20 let _admin = _generated::admin::build_admin();\n\
\x20 println!(\"rustio-admin: scaffold ready. Edit src/main.rs to wire your server.\");\n\
}\n";
std::fs::write(root.join("src/main.rs"), main_rs).map_err(|e| e.to_string())?;
Ok(format!(
"Created project '{name}'.\nNext: cd {name} && rustio-admin add model <Name> && rustio-admin commit"
))
}
pub(crate) fn run_add_model(start: &Path, model_name: &str) -> Result<String, String> {
validate_model_name(model_name)?;
let root = find_project_root(start).map_err(format_lifecycle_err)?;
let history_path = root.join(".rustio/history.jsonl");
preflight(&root)?;
let table = plural_snake(model_name);
let (actor, source) = resolve_actor();
warn_if_degraded(source);
let id = append(
&history_path,
HistoryOp::AddModel,
&actor,
json!({"name": model_name, "table": table.clone()}),
)
.map_err(|e| e.to_string())?;
redraft(&root)?;
Ok(format!(
"Recorded add_model {model_name} (table = {table}) [event {id}]\n\
Run `rustio-admin commit` to generate code."
))
}
pub(crate) fn run_add_field(
start: &Path,
model_name: &str,
field_name: &str,
type_name: &str,
unique: bool,
) -> Result<String, String> {
validate_model_name(model_name)?;
validate_field_name(field_name)?;
if !FIELD_TYPES.contains(&type_name) {
return Err(format!(
"type '{type_name}' is not in the closed MVP type list {FIELD_TYPES:?}"
));
}
if is_secret_field_type(type_name) {
return Err(format!(
"field type '{type_name}' is a secret-category type (DESIGN_BUILDER.md §4.2.3). \
MVP refuses these until the `--default` and `redact = true` plumbing lands; the \
event log has no path to redact the future defaults safely."
));
}
let root = find_project_root(start).map_err(format_lifecycle_err)?;
preflight(&root)?;
let history_path = root.join(".rustio/history.jsonl");
let draft =
crate::builder::replay::replay_from_file(&history_path).map_err(|e| e.to_string())?;
if !draft.models.iter().any(|m| m.name == model_name) {
return Err(format!(
"model '{model_name}' is not registered; run `rustio-admin add model {model_name}` first"
));
}
if draft
.models
.iter()
.find(|m| m.name == model_name)
.map(|m| m.fields.iter().any(|f| f.name == field_name))
.unwrap_or(false)
{
return Err(format!(
"field {model_name}.{field_name} is already declared"
));
}
let (actor, source) = resolve_actor();
warn_if_degraded(source);
let id = append(
&history_path,
HistoryOp::AddField,
&actor,
json!({
"model": model_name,
"name": field_name,
"type": type_name,
"required": true,
"unique": unique,
}),
)
.map_err(|e| e.to_string())?;
redraft(&root)?;
Ok(format!(
"Recorded add_field {model_name}.{field_name}: {type_name} [event {id}]\n\
Run `rustio-admin commit` to regenerate code."
))
}
pub(crate) fn run_import(start: &Path, schema_path: &Path) -> Result<String, String> {
let raw = std::fs::read_to_string(schema_path)
.map_err(|e| format!("could not read {}: {e}", schema_path.display()))?;
let doc: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("{}: invalid JSON: {e}", schema_path.display()))?;
let models = doc
.get("models")
.and_then(|m| m.as_array())
.ok_or_else(|| "schema must have a top-level \"models\" array".to_string())?;
if models.is_empty() {
return Err("schema \"models\" is empty — nothing to import".to_string());
}
find_project_root(start).map_err(format_lifecycle_err)?;
struct ImpModel {
name: String,
fields: Vec<(String, String, bool)>,
}
let mut planned: Vec<ImpModel> = Vec::with_capacity(models.len());
for (mi, m) in models.iter().enumerate() {
let name = m
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("models[{mi}] is missing a string \"name\""))?;
validate_model_name(name)?;
let mut fields = Vec::new();
if let Some(farr) = m.get("fields").and_then(|f| f.as_array()) {
for (fi, f) in farr.iter().enumerate() {
let fname = f
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("{name}.fields[{fi}] is missing a string \"name\""))?;
let fty = f
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("{name}.{fname} is missing a string \"type\""))?;
validate_field_name(fname)?;
if !FIELD_TYPES.contains(&fty) {
return Err(format!(
"{name}.{fname}: type '{fty}' is not in the closed list {FIELD_TYPES:?}"
));
}
if is_secret_field_type(fty) {
return Err(format!(
"{name}.{fname}: secret-category type '{fty}' is refused (DESIGN_BUILDER.md §4.2.3)"
));
}
let unique = f.get("unique").and_then(|v| v.as_bool()).unwrap_or(false);
fields.push((fname.to_string(), fty.to_string(), unique));
}
}
planned.push(ImpModel {
name: name.to_string(),
fields,
});
}
let mut n_models = 0usize;
let mut n_fields = 0usize;
for item in &planned {
run_add_model(start, &item.name)?;
n_models += 1;
for (fname, fty, unique) in &item.fields {
run_add_field(start, &item.name, fname, fty, *unique)?;
n_fields += 1;
}
}
Ok(format!(
"Imported {n_models} model(s) and {n_fields} field(s) from {}.\n\
Run `rustio-admin plan` to preview, then `rustio-admin commit` to generate code.",
schema_path.display()
))
}
pub(crate) fn run_plan(start: &Path) -> Result<String, String> {
let report = lifecycle_plan(start).map_err(format_lifecycle_err)?;
Ok(render_plan(&report))
}
pub(crate) fn run_commit(start: &Path, force: bool) -> Result<String, String> {
let (actor, source) = resolve_actor();
warn_if_degraded(source);
let result = lifecycle_commit(start, force, &actor).map_err(format_lifecycle_err)?;
match result {
CommitResult::NoOp => Ok("Nothing to do -- project is in sync with draft.".to_string()),
CommitResult::Wrote { event_id, files } => {
let mut out = format!("Committed {} file(s) [event {event_id}]\n", files.len());
for f in &files {
out.push_str(&format!(" + {}\n", f.display()));
}
Ok(out)
}
}
}
fn preflight(root: &Path) -> Result<(), String> {
let lock_text =
std::fs::read_to_string(root.join(".rustio/builder.lock")).map_err(|e| e.to_string())?;
let lock = BuilderLock::from_toml(&lock_text).map_err(|e| e.to_string())?;
lock.verify_against_running().map_err(|e| e.to_string())?;
Ok(())
}
fn redraft(root: &Path) -> Result<(), String> {
let history_path = root.join(".rustio/history.jsonl");
let draft =
crate::builder::replay::replay_from_file(&history_path).map_err(|e| e.to_string())?;
std::fs::write(root.join(".rustio/draft.toml"), draft.to_toml()).map_err(|e| e.to_string())?;
Ok(())
}
fn format_lifecycle_err(e: LifecycleError) -> String {
e.to_string()
}
fn render_plan(report: &PlanReport) -> String {
let mut out = String::new();
out.push_str(&format!(
"Project root: {}\n\n",
report.project_root.display()
));
if report.is_no_op() {
out.push_str("Plan: no changes. Project is in sync with draft.\n");
return out;
}
out.push_str("Plan:\n");
for entry in &report.entries {
out.push_str(&render_entry(entry));
}
if let Some(m) = &report.migration {
out.push_str(&render_entry(m));
}
out
}
fn render_entry(entry: &crate::builder::lifecycle::PlanEntry) -> String {
let marker = match &entry.verdict {
FileVerdict::Create => "+ create ",
FileVerdict::NoOp => "= no-op ",
FileVerdict::Mismatch { .. } => "! mismatch ",
FileVerdict::Unowned => "! unowned ",
};
format!(" {} {}\n", marker, entry.path.display())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::ulid_gen::new_ulid;
fn tempdir() -> PathBuf {
let base = std::env::temp_dir().join(format!(
"rustio-cmd-test-{}-{}",
std::process::id(),
new_ulid()
));
std::fs::create_dir_all(&base).unwrap();
base
}
#[test]
fn plural_snake_handles_common_cases() {
assert_eq!(plural_snake("Patient"), "patients");
assert_eq!(plural_snake("Doctor"), "doctors");
assert_eq!(plural_snake("BlogPost"), "blog_posts");
assert_eq!(plural_snake("Box"), "boxes");
assert_eq!(plural_snake("Story"), "stories");
assert_eq!(plural_snake("Status"), "status"); }
#[test]
fn project_name_validator_refuses_garbage() {
assert!(validate_project_name("").is_err());
assert!(validate_project_name("1abc").is_err());
assert!(validate_project_name("a/b").is_err());
assert!(validate_project_name("a b").is_err());
assert!(validate_project_name("valid-name_1").is_ok());
}
#[test]
fn model_name_validator_requires_camel_case() {
assert!(validate_model_name("patient").is_err());
assert!(validate_model_name("Patient").is_ok());
assert!(validate_model_name("BlogPost").is_ok());
assert!(validate_model_name("Patient!").is_err());
}
#[test]
fn field_name_validator_refuses_reserved_names() {
assert!(validate_field_name("id").is_err());
assert!(validate_field_name("created_at").is_err());
assert!(validate_field_name("FullName").is_err());
assert!(validate_field_name("full_name").is_ok());
}
#[test]
fn end_to_end_lifecycle() {
let work = tempdir();
let project_name = "demo-project";
let project_root = work.join(project_name);
let summary = {
let _guard = CWD_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&work).unwrap();
let res = run_new(project_name);
std::env::set_current_dir(&original_cwd).unwrap();
res.unwrap()
};
assert!(summary.contains("Created project"));
for rel in [
".rustio/draft.toml",
".rustio/history.jsonl",
".rustio/builder.lock",
"Cargo.toml",
"src/main.rs",
] {
assert!(
project_root.join(rel).exists(),
"{rel} must exist after rustio-admin new"
);
}
let msg = run_add_model(&project_root, "Patient").unwrap();
assert!(msg.contains("add_model Patient"));
let msg = run_add_field(&project_root, "Patient", "full_name", "text", false).unwrap();
assert!(msg.contains("add_field Patient.full_name"));
let plan_out = run_plan(&project_root).unwrap();
assert!(plan_out.contains("create"));
assert!(plan_out.contains("0001_initial.sql"));
let commit_out = run_commit(&project_root, false).unwrap();
assert!(commit_out.starts_with("Committed"));
assert!(project_root
.join("src/_generated/models/patient.rs")
.exists());
assert!(project_root.join("migrations/0001_initial.sql").exists());
let again = run_commit(&project_root, false).unwrap();
assert!(
again.contains("Nothing to do"),
"second commit must be a no-op, got: {again}",
);
}
fn bootstrap_project_at(root: &Path) {
std::fs::create_dir_all(root.join(".rustio")).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("migrations")).unwrap();
std::fs::write(
root.join(".rustio/builder.lock"),
crate::builder::lockfile::BuilderLock::current().to_toml(),
)
.unwrap();
let mut draft = crate::builder::draft::Draft::empty();
draft.project = crate::builder::draft::Project {
name: "p".into(),
rust_version: env!("CARGO_PKG_RUST_VERSION").into(),
builder_pinned: env!("CARGO_PKG_VERSION").into(),
created_at: "2026-05-15T10:30:00Z".into(),
};
std::fs::write(root.join(".rustio/draft.toml"), draft.to_toml()).unwrap();
crate::builder::history::append(
&root.join(".rustio/history.jsonl"),
HistoryOp::ProjectInit,
"test@example.com",
json!({
"name": "p",
"rust_version": env!("CARGO_PKG_RUST_VERSION"),
"builder_pinned": env!("CARGO_PKG_VERSION"),
"created_at": "2026-05-15T10:30:00Z",
}),
)
.unwrap();
}
#[test]
fn add_field_refuses_unknown_type() {
let root = tempdir();
bootstrap_project_at(&root);
run_add_model(&root, "Item").unwrap();
let err = run_add_field(&root, "Item", "weird", "geography", false).unwrap_err();
assert!(err.contains("not in the closed MVP type list"), "{err}");
}
#[test]
fn import_loads_models_and_fields() {
let root = tempdir();
bootstrap_project_at(&root);
let schema = root.join("schema.json");
std::fs::write(
&schema,
r#"{ "project": "demo", "models": [
{ "name": "Invoice", "fields": [
{ "name": "amount", "type": "integer" },
{ "name": "issued_at", "type": "timestamp" },
{ "name": "paid", "type": "boolean" } ] },
{ "name": "Client", "fields": [
{ "name": "full_name", "type": "text" } ] } ] }"#,
)
.unwrap();
let msg = run_import(&root, &schema).unwrap();
assert!(msg.contains("Imported 2 model(s) and 4 field(s)"), "{msg}");
let draft =
crate::builder::replay::replay_from_file(&root.join(".rustio/history.jsonl")).unwrap();
let invoice = draft.models.iter().find(|m| m.name == "Invoice").unwrap();
assert!(invoice.fields.iter().any(|f| f.name == "amount"));
assert!(invoice.fields.iter().any(|f| f.name == "paid"));
assert!(draft.models.iter().any(|m| m.name == "Client"));
}
#[test]
fn import_rejects_bad_type_and_records_nothing() {
let root = tempdir();
bootstrap_project_at(&root);
let schema = root.join("schema.json");
std::fs::write(
&schema,
r#"{ "models": [ { "name": "X", "fields": [
{ "name": "f", "type": "geography" } ] } ] }"#,
)
.unwrap();
let err = run_import(&root, &schema).unwrap_err();
assert!(err.contains("not in the closed list"), "{err}");
let draft =
crate::builder::replay::replay_from_file(&root.join(".rustio/history.jsonl")).unwrap();
assert!(
!draft.models.iter().any(|m| m.name == "X"),
"a rejected import must record nothing"
);
}
#[test]
fn add_field_refuses_secret_category_types() {
let root = tempdir();
bootstrap_project_at(&root);
run_add_model(&root, "User").unwrap();
for ty in [
"password",
"secret",
"token",
"api_key",
"private_key",
"encryption_key",
] {
let err = run_add_field(&root, "User", "secret_field", ty, false)
.expect_err(&format!("type {ty} must be refused"));
assert!(
err.contains("not in the closed MVP type list") || err.contains("secret-category")
);
}
}
#[test]
fn add_field_refuses_unknown_model() {
let root = tempdir();
bootstrap_project_at(&root);
let err = run_add_field(&root, "Ghost", "x", "text", false).unwrap_err();
assert!(err.contains("is not registered"), "{err}");
}
#[test]
fn new_refuses_existing_directory() {
let _guard = CWD_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let work = tempdir();
let original_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&work).unwrap();
std::fs::create_dir(work.join("collide")).unwrap();
let err = run_new("collide").unwrap_err();
std::env::set_current_dir(&original_cwd).unwrap();
assert!(err.contains("already exists"));
}
static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
}