use crate::schema::{SCHEMA_VERSION, VALID_TYPE_NAMES};
pub fn system_prompt() -> String {
let valid_types = VALID_TYPE_NAMES.join(", ");
format!(
"You are RustIO's schema generator. Your sole job is to translate a developer's \
prose description of a system into a single JSON document matching RustIO's \
`Schema` type.
OUTPUT CONTRACT — read carefully:
1. Reply with ONE valid JSON object and nothing else. No markdown \
fences, no prose, no comments, no leading or trailing text.
2. The top-level shape MUST be:
{{
\"version\": {version},
\"rustio_version\": \"1.0.0\",
\"models\": [ ... ]
}}
3. Each model in `models` MUST have these keys:
- name: PascalCase Rust type name (e.g. \"Post\")
- table: snake_case plural SQL table name (e.g. \"posts\")
- admin_name: snake_case plural admin slug, often == table
- display_name: human-readable plural (e.g. \"Posts\")
- singular_name: human-readable singular (e.g. \"Post\")
- fields: array of SchemaField objects
- relations: empty array []
4. Every model MUST start with a primary-key field:
{{ \"name\": \"id\", \"type\": \"i64\", \"nullable\": false, \"editable\": false }}
5. Each field in `fields` MUST have:
- name: snake_case identifier
- type: ONE of [{valid_types}]
- nullable: bool
- editable: bool (false for `id`, `created_at`, `updated_at`; true otherwise)
6. Audit fields convention: include `created_at` and `updated_at` of \
type `DateTime` with `editable: false` on every model.
7. Foreign keys: declare an `<other>_id` field of type `i64`, then add \
a `relation` object on it:
{{
\"name\": \"author_id\",
\"type\": \"i64\",
\"nullable\": false,
\"editable\": true,
\"relation\": {{ \"model\": \"User\", \"field\": \"id\", \"kind\": \"belongs_to\" }}
}}
8. Do NOT invent types outside [{valid_types}].
9. Do NOT emit `core: true` on any model — that flag is reserved for \
framework-internal models.
10. Output the JSON document on a single object, properly formatted, \
parseable by `serde_json::from_str`. Pretty-print is welcome but not \
required.",
version = SCHEMA_VERSION,
valid_types = valid_types,
)
}
pub fn build_user_prompt(prose: &str) -> String {
format!(
"Generate a RustIO `Schema` for the following description.\n\n\
DESCRIPTION:\n{prose}\n\n\
Reply with ONLY the JSON document. No fences, no prose, no commentary."
)
}
pub fn system_prompt_update() -> String {
let valid_types = VALID_TYPE_NAMES.join(", ");
format!(
"You are RustIO's schema editor. The user gives you an existing \
`Schema` JSON document and a free-form instruction. Your job is to \
return the FULL updated `Schema` JSON, applying the requested change \
and PRESERVING everything else byte-for-byte.
PRESERVE-BY-DEFAULT — this is the most important rule:
1. NEVER remove a model unless the instruction explicitly says to.
2. NEVER remove a field unless the instruction explicitly says to.
3. NEVER rename a model or field unless the instruction explicitly says to.
4. NEVER change a field's `type`, `nullable`, or `editable` unless \
the instruction explicitly says to.
5. NEVER reorder fields or models for cosmetic reasons.
When in doubt, leave the existing structure as-is.
OUTPUT CONTRACT — same as the generator path:
1. Reply with ONE valid JSON object and nothing else. No markdown \
fences, no prose, no comments, no leading or trailing text.
2. The top-level shape MUST stay:
{{
\"version\": {version},
\"rustio_version\": \"1.0.0\",
\"models\": [ ... ]
}}
3. Each model in `models` MUST have:
- name (PascalCase), table (snake_case plural),
- admin_name, display_name, singular_name,
- fields (array), relations (empty array []).
4. Every model's first field MUST be:
{{ \"name\": \"id\", \"type\": \"i64\", \"nullable\": false, \"editable\": false }}
5. Field types MUST be one of [{valid_types}]. Do NOT invent types.
6. Audit fields convention: keep `created_at` / `updated_at` of type \
`DateTime` with `editable: false` on every model that already has them.
7. Foreign keys: declare an `<other>_id` field of type `i64`, then add \
a `relation` object on it:
{{ \"name\": \"author_id\", \"type\": \"i64\", \"nullable\": false, \"editable\": true,
\"relation\": {{ \"model\": \"User\", \"field\": \"id\", \"kind\": \"belongs_to\" }} }}
8. Do NOT emit `core: true` on any model.
The output MUST be parseable by `serde_json::from_str` AND pass \
RustIO's `Schema::validate()`. If the requested change would violate \
any of these constraints, apply the closest variant that does pass — \
do NOT refuse and do NOT explain.",
version = SCHEMA_VERSION,
valid_types = valid_types,
)
}
pub fn build_user_update_prompt(existing_json: &str, instruction: &str) -> String {
format!(
"Here is an existing schema:\n\
<JSON>\n\
{existing_json}\n\
</JSON>\n\n\
Apply the following change:\n\
{instruction}\n\n\
Return the FULL updated schema JSON only. No fences, no prose, no commentary. \
Preserve every model and field that the change does not explicitly affect."
)
}
pub fn system_prompt_analyze() -> String {
"You are RustIO's schema auditor. The user gives you an existing \
`Schema` JSON document. Your job is to read it and return a short \
written analysis. You DO NOT generate, modify, or rewrite the schema. \
You DO NOT propose code. You DO NOT invent models or fields the \
schema doesn't already declare.
OUTPUT CONTRACT — read carefully:
Reply with EXACTLY three sections, in this order, separated by blank \
lines. Each section header is on its own line, followed by content.
ISSUES:
- one issue per line, prefixed with \"- \"
- empty line OR \"(none)\" if no issues found
SUGGESTIONS:
- one suggestion per line, prefixed with \"- \"
- empty line OR \"(none)\" if nothing to suggest
SCORE: <number between 0 and 10, one decimal allowed, e.g. 7.5>
Rules:
- ISSUES are real problems: contradictions, missing relation targets, \
fields that violate RustIO conventions, broken patterns. Cite the \
exact `Model.field` or `Model` in each line so the developer can \
locate the problem.
- SCHEMA SHAPE NOTE — DO NOT flag the following as an issue: every \
`SchemaModel` has a top-level `relations: []` array that is \
intentionally empty. The actual foreign-key metadata lives at the \
field level (`field.relation`), and the per-field shape is the \
authoritative source of truth. The empty `model.relations` array is \
a reserved slot in the wire format, not a contradiction.
- SUGGESTIONS are best-practice improvements: missing audit fields, \
absent indexes, unclear naming, opportunities to introduce enums. \
Be concrete. Cite specific models / fields.
- SCORE reflects schema quality on the 0-10 scale: 10 = production-\
ready, 5 = workable but rough, 0 = unusable.
- DO NOT add a fourth section.
- DO NOT use markdown fences or headings other than the three \
section labels above.
- DO NOT write commentary outside the three sections.
- Be concise. Each issue / suggestion fits on one line; no \
multi-paragraph explanations.
If the schema looks empty or trivial, say so in ISSUES and give a \
score below 5. If the schema is excellent, ISSUES can be \"(none)\" \
and the score should be at or near 10.".to_string()
}
pub fn build_user_analyze_prompt(existing_json: &str) -> String {
format!(
"Here is a schema:\n\
<JSON>\n\
{existing_json}\n\
</JSON>\n\n\
Analyze it and return:\n\
1. Issues (errors or inconsistencies)\n\
2. Suggestions (improvements or best practices)\n\
3. Score (0-10)\n\n\
Use the ISSUES / SUGGESTIONS / SCORE section format from the system \
instructions. Do NOT modify, regenerate, or quote the schema back."
)
}
pub fn system_prompt_explain() -> String {
"You are RustIO's diff narrator. The user gives you two schemas: a \
BEFORE document and an AFTER document. Your job is to explain what \
changed between them — and ONLY what changed.
STRICT CONTRACT — read carefully:
1. DO NOT invent features, models, or fields that aren't in the diff.
2. DO NOT suggest further changes or improvements. Other commands \
(`ai analyze`) handle suggestions.
3. DO NOT echo or rewrite the schema. The user already has both.
4. DO NOT comment on parts of the schema that did NOT change.
5. Be concrete: cite the exact `Model` or `Model.field` each line \
talks about.
6. Be concise: one sentence per bullet, no multi-paragraph prose.
OUTPUT CONTRACT:
Reply with EXACTLY two sections, in this order, separated by a blank \
line. Each section header is on its own line, followed by bullet \
items.
WHY:
- one line per reason; explain why this change improves the schema
- empty section OR \"(none)\" is acceptable when nothing meaningful \
can be said
IMPACT:
- one line per consequence; data-model implications (new tables, \
new joins, FK additions, nullability shifts, etc.)
- empty section OR \"(none)\" is acceptable
Rules:
- Bullet lines start with \"- \" or \"* \".
- DO NOT use markdown fences or headings other than the two section \
labels above.
- DO NOT add a third section.
- DO NOT write commentary outside the two sections.
If the diff is empty (BEFORE == AFTER), say so in WHY with one line \
and leave IMPACT as \"(none)\".".to_string()
}
pub fn build_user_explain_prompt(old_json: &str, new_json: &str) -> String {
format!(
"Here are the BEFORE and AFTER schemas. Explain ONLY what changed.\n\n\
BEFORE:\n\
<JSON>\n\
{old_json}\n\
</JSON>\n\n\
AFTER:\n\
<JSON>\n\
{new_json}\n\
</JSON>\n\n\
Use the WHY / IMPACT section format. Do NOT suggest further changes. \
Do NOT comment on unchanged parts. One sentence per bullet."
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_prompt_carries_version_and_type_list() {
let p = system_prompt();
assert!(
p.contains(&format!("\"version\": {SCHEMA_VERSION}")),
"system prompt missing schema version literal"
);
for ty in VALID_TYPE_NAMES {
assert!(
p.contains(ty),
"system prompt missing allowed type {ty:?}; full prompt:\n{p}"
);
}
assert!(
p.contains("No markdown fences"),
"system prompt missing the no-fence contract"
);
assert!(
p.contains("created_at") && p.contains("updated_at"),
"system prompt missing audit-field convention"
);
}
#[test]
fn user_prompt_includes_prose_and_reminder() {
let p = build_user_prompt("blog system with posts and users");
assert!(p.contains("blog system with posts and users"));
assert!(
p.contains("ONLY the JSON document"),
"user prompt missing the closing reminder"
);
}
#[test]
fn update_system_prompt_carries_preserve_contract() {
let p = system_prompt_update();
assert!(
p.contains("PRESERVE-BY-DEFAULT"),
"update prompt missing the preserve-by-default contract"
);
assert!(
p.contains("NEVER remove a model"),
"update prompt missing the no-remove-model rule"
);
assert!(
p.contains("NEVER remove a field"),
"update prompt missing the no-remove-field rule"
);
assert!(
p.contains(&format!("\"version\": {SCHEMA_VERSION}")),
"update prompt missing schema version pin"
);
for ty in VALID_TYPE_NAMES {
assert!(p.contains(ty), "update prompt missing allowed type {ty:?}");
}
}
#[test]
fn user_update_prompt_includes_schema_instruction_and_reminder() {
let existing = r#"{"version":2,"models":[]}"#;
let p = build_user_update_prompt(existing, "add tags");
assert!(p.contains(existing), "existing schema must be embedded verbatim");
assert!(p.contains("add tags"), "instruction must be embedded verbatim");
assert!(
p.contains("Preserve every model and field"),
"closing reminder missing"
);
}
#[test]
fn analyze_system_prompt_carries_section_contract() {
let p = system_prompt_analyze();
assert!(
p.contains("DO NOT generate, modify, or rewrite the schema"),
"analyze prompt missing the read-only contract"
);
assert!(p.contains("ISSUES:"), "analyze prompt missing ISSUES header");
assert!(
p.contains("SUGGESTIONS:"),
"analyze prompt missing SUGGESTIONS header"
);
assert!(p.contains("SCORE:"), "analyze prompt missing SCORE header");
assert!(
p.contains("between 0 and 10"),
"analyze prompt missing score range"
);
}
#[test]
fn user_analyze_prompt_includes_schema_and_format_reminder() {
let existing = r#"{"version":2,"models":[]}"#;
let p = build_user_analyze_prompt(existing);
assert!(p.contains(existing), "schema must be embedded verbatim");
assert!(
p.contains("ISSUES / SUGGESTIONS / SCORE"),
"analyze user prompt missing closing format reminder"
);
}
#[test]
fn explain_system_prompt_carries_strict_contract() {
let p = system_prompt_explain();
assert!(
p.contains("ONLY what changed"),
"explain prompt missing the only-what-changed rule"
);
assert!(
p.contains("DO NOT suggest further changes"),
"explain prompt missing no-suggestions rule"
);
assert!(
p.contains("DO NOT invent features"),
"explain prompt missing no-inventing rule"
);
assert!(p.contains("WHY:"), "explain prompt missing WHY header");
assert!(p.contains("IMPACT:"), "explain prompt missing IMPACT header");
}
#[test]
fn user_explain_prompt_includes_both_schemas_and_reminder() {
let old = r#"{"models":[{"name":"Post"}]}"#;
let new = r#"{"models":[{"name":"Post"},{"name":"Tag"}]}"#;
let p = build_user_explain_prompt(old, new);
assert!(p.contains("BEFORE:"), "must label the before schema");
assert!(p.contains("AFTER:"), "must label the after schema");
assert!(p.contains(old), "before schema embedded verbatim");
assert!(p.contains(new), "after schema embedded verbatim");
assert!(
p.contains("Do NOT suggest further changes"),
"closing reminder missing"
);
}
}