1use serde::{Deserialize, Serialize};
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct ProjectSketch {
40 pub domain: &'static str,
42 pub headline: &'static str,
44 pub user_description: String,
46 pub models: Vec<ModelSketch>,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct ModelSketch {
57 pub struct_name: &'static str,
59 pub table: &'static str,
62 pub fields: Vec<FieldSketch>,
63 pub rationale: &'static str,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct FieldSketch {
73 pub name: &'static str,
74 pub ty: &'static str,
77 #[serde(default)]
78 pub nullable: bool,
79 #[serde(default)]
84 pub belongs_to: Option<&'static str>,
85}
86
87pub fn sketch(description: &str) -> Option<ProjectSketch> {
101 let lower = description.to_lowercase();
102 for (keywords, build) in DOMAIN_TABLE {
103 if keywords.iter().any(|k| lower.contains(k)) {
104 return Some(build(description.to_string()));
105 }
106 }
107 None
108}
109
110type DomainBuilder = fn(String) -> ProjectSketch;
114const DOMAIN_TABLE: &[(&[&str], DomainBuilder)] = &[
115 (
116 &[
117 "clinic",
118 "patient",
119 "doctor",
120 "appointment",
121 "hospital",
122 "medical",
123 ],
124 clinic_sketch,
125 ),
126 (
127 &["blog", "post", "article", "comment", "publish"],
128 blog_sketch,
129 ),
130 (
131 &[
132 "shop",
133 "store",
134 "product",
135 "inventory",
136 "stock",
137 "sku",
138 "order",
139 ],
140 shop_sketch,
141 ),
142 (
143 &[
144 "crm",
145 "customer",
146 "lead",
147 "deal",
148 "contact",
149 "sales pipeline",
150 ],
151 crm_sketch,
152 ),
153 (
154 &["task", "todo", "project", "ticket", "issue", "kanban"],
155 tasks_sketch,
156 ),
157];
158
159fn clinic_sketch(description: String) -> ProjectSketch {
173 ProjectSketch {
174 domain: "clinic",
175 headline: "A small clinic — patients, doctors, appointments.",
176 user_description: description,
177 models: vec![
178 ModelSketch {
179 struct_name: "Patient",
180 table: "patients",
181 rationale: "Each person you treat. Name is required; date of birth is useful for the chart, phone for reminders.",
182 fields: vec![
183 FieldSketch { name: "name", ty: "String", nullable: false, belongs_to: None },
184 FieldSketch { name: "date_of_birth", ty: "DateTime", nullable: true, belongs_to: None },
185 FieldSketch { name: "phone", ty: "String", nullable: true, belongs_to: None },
186 ],
187 },
188 ModelSketch {
189 struct_name: "Doctor",
190 table: "doctors",
191 rationale: "The staff who see patients. Specialty helps when scheduling.",
192 fields: vec![
193 FieldSketch { name: "name", ty: "String", nullable: false, belongs_to: None },
194 FieldSketch { name: "specialty", ty: "String", nullable: true, belongs_to: None },
195 ],
196 },
197 ModelSketch {
198 struct_name: "Appointment",
199 table: "appointments",
200 rationale: "A scheduled visit — links a patient to a doctor with a time.",
201 fields: vec![
202 FieldSketch { name: "patient_id", ty: "i64", nullable: false, belongs_to: Some("Patient") },
203 FieldSketch { name: "doctor_id", ty: "i64", nullable: false, belongs_to: Some("Doctor") },
204 FieldSketch { name: "scheduled_for", ty: "DateTime", nullable: false, belongs_to: None },
205 FieldSketch { name: "notes", ty: "String", nullable: true, belongs_to: None },
206 ],
207 },
208 ],
209 }
210}
211
212fn blog_sketch(description: String) -> ProjectSketch {
213 ProjectSketch {
214 domain: "blog",
215 headline: "A blog — authors and posts.",
216 user_description: description,
217 models: vec![
218 ModelSketch {
219 struct_name: "Author",
220 table: "authors",
221 rationale: "The people who write. Name is required; bio is optional.",
222 fields: vec![
223 FieldSketch {
224 name: "name",
225 ty: "String",
226 nullable: false,
227 belongs_to: None,
228 },
229 FieldSketch {
230 name: "bio",
231 ty: "String",
232 nullable: true,
233 belongs_to: None,
234 },
235 ],
236 },
237 ModelSketch {
238 struct_name: "Post",
239 table: "posts",
240 rationale: "One article. Title, body, and a publication timestamp.",
241 fields: vec![
242 FieldSketch {
243 name: "author_id",
244 ty: "i64",
245 nullable: false,
246 belongs_to: Some("Author"),
247 },
248 FieldSketch {
249 name: "title",
250 ty: "String",
251 nullable: false,
252 belongs_to: None,
253 },
254 FieldSketch {
255 name: "body",
256 ty: "String",
257 nullable: false,
258 belongs_to: None,
259 },
260 FieldSketch {
261 name: "published_at",
262 ty: "DateTime",
263 nullable: true,
264 belongs_to: None,
265 },
266 ],
267 },
268 ],
269 }
270}
271
272fn shop_sketch(description: String) -> ProjectSketch {
273 ProjectSketch {
274 domain: "shop",
275 headline: "A small shop — products and orders.",
276 user_description: description,
277 models: vec![
278 ModelSketch {
279 struct_name: "Product",
280 table: "products",
281 rationale: "What you sell. SKU is the unique identifier; stock is what's on hand.",
282 fields: vec![
283 FieldSketch { name: "sku", ty: "String", nullable: false, belongs_to: None },
284 FieldSketch { name: "name", ty: "String", nullable: false, belongs_to: None },
285 FieldSketch { name: "price_cents",ty: "i64", nullable: false, belongs_to: None },
286 FieldSketch { name: "stock", ty: "i64", nullable: false, belongs_to: None },
287 ],
288 },
289 ModelSketch {
290 struct_name: "Order",
291 table: "orders",
292 rationale: "A single transaction. Carries the buyer's email so you can reach them without a separate Customer table on day one.",
293 fields: vec![
294 FieldSketch { name: "product_id", ty: "i64", nullable: false, belongs_to: Some("Product") },
295 FieldSketch { name: "quantity", ty: "i64", nullable: false, belongs_to: None },
296 FieldSketch { name: "buyer_email", ty: "String", nullable: false, belongs_to: None },
297 FieldSketch { name: "placed_at", ty: "DateTime", nullable: false, belongs_to: None },
298 ],
299 },
300 ],
301 }
302}
303
304fn crm_sketch(description: String) -> ProjectSketch {
305 ProjectSketch {
306 domain: "crm",
307 headline: "A small CRM — companies, contacts, deals.",
308 user_description: description,
309 models: vec![
310 ModelSketch {
311 struct_name: "Company",
312 table: "companies",
313 rationale: "An organisation you might sell to.",
314 fields: vec![
315 FieldSketch { name: "name", ty: "String", nullable: false, belongs_to: None },
316 FieldSketch { name: "website", ty: "String", nullable: true, belongs_to: None },
317 ],
318 },
319 ModelSketch {
320 struct_name: "Contact",
321 table: "contacts",
322 rationale: "A person at a company. Belongs to one Company.",
323 fields: vec![
324 FieldSketch { name: "company_id", ty: "i64", nullable: false, belongs_to: Some("Company") },
325 FieldSketch { name: "name", ty: "String", nullable: false, belongs_to: None },
326 FieldSketch { name: "email", ty: "String", nullable: true, belongs_to: None },
327 FieldSketch { name: "phone", ty: "String", nullable: true, belongs_to: None },
328 ],
329 },
330 ModelSketch {
331 struct_name: "Deal",
332 table: "deals",
333 rationale: "An opportunity. Linked to a Contact; status tracks stage; amount is in cents to keep arithmetic clean.",
334 fields: vec![
335 FieldSketch { name: "contact_id", ty: "i64", nullable: false, belongs_to: Some("Contact") },
336 FieldSketch { name: "title", ty: "String", nullable: false, belongs_to: None },
337 FieldSketch { name: "status", ty: "String", nullable: false, belongs_to: None },
338 FieldSketch { name: "amount_cents",ty: "i64", nullable: true, belongs_to: None },
339 FieldSketch { name: "closed_at", ty: "DateTime", nullable: true, belongs_to: None },
340 ],
341 },
342 ],
343 }
344}
345
346fn tasks_sketch(description: String) -> ProjectSketch {
347 ProjectSketch {
348 domain: "tasks",
349 headline: "A task tracker — projects and tasks.",
350 user_description: description,
351 models: vec![
352 ModelSketch {
353 struct_name: "Project",
354 table: "projects",
355 rationale: "A container for related tasks.",
356 fields: vec![
357 FieldSketch { name: "name", ty: "String", nullable: false, belongs_to: None },
358 FieldSketch { name: "description", ty: "String", nullable: true, belongs_to: None },
359 ],
360 },
361 ModelSketch {
362 struct_name: "Task",
363 table: "tasks",
364 rationale: "One thing to do. Status moves from todo → in_progress → done; priority is a small integer.",
365 fields: vec![
366 FieldSketch { name: "project_id", ty: "i64", nullable: false, belongs_to: Some("Project") },
367 FieldSketch { name: "title", ty: "String", nullable: false, belongs_to: None },
368 FieldSketch { name: "status", ty: "String", nullable: false, belongs_to: None },
369 FieldSketch { name: "priority", ty: "i64", nullable: true, belongs_to: None },
370 FieldSketch { name: "due_at", ty: "DateTime", nullable: true, belongs_to: None },
371 ],
372 },
373 ],
374 }
375}
376
377use crate::ai::{AddModel, AddRelation, FieldSpec, Plan, Primitive, RelationKind};
380
381pub fn primitives_for(model: &ModelSketch) -> Vec<Primitive> {
389 let fields: Vec<FieldSpec> = model
390 .fields
391 .iter()
392 .map(|f| FieldSpec {
393 name: f.name.to_string(),
394 ty: f.ty.to_string(),
395 nullable: f.nullable,
396 editable: true,
397 })
398 .collect();
399
400 let mut out: Vec<Primitive> = Vec::with_capacity(1 + model.fields.len());
401 out.push(Primitive::AddModel(AddModel {
402 name: model.struct_name.to_string(),
403 table: model.table.to_string(),
404 fields,
405 }));
406 for f in &model.fields {
407 if let Some(target) = f.belongs_to {
408 out.push(Primitive::AddRelation(AddRelation {
414 from: model.struct_name.to_string(),
415 kind: RelationKind::BelongsTo,
416 to: target.to_string(),
417 via: f.name.to_string(),
418 required: false,
419 on_delete: Default::default(),
420 }));
421 }
422 }
423 out
424}
425
426pub fn plan_for(accepted: &[ModelSketch]) -> Plan {
431 let mut steps: Vec<Primitive> = Vec::new();
432 for m in accepted {
433 steps.extend(primitives_for(m));
434 }
435 Plan { steps }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn clinic_keyword_yields_clinic_sketch() {
444 let s = sketch("a small clinic with patients and appointments").unwrap();
445 assert_eq!(s.domain, "clinic");
446 let names: Vec<&str> = s.models.iter().map(|m| m.struct_name).collect();
447 assert_eq!(names, vec!["Patient", "Doctor", "Appointment"]);
448 }
449
450 #[test]
451 fn ambiguous_input_refuses() {
452 assert!(sketch("I want to build something").is_none());
453 assert!(sketch("").is_none());
454 }
455
456 #[test]
457 fn shop_template_uses_only_valid_types() {
458 use crate::schema::VALID_TYPE_NAMES;
459 let s = sketch("a shop with products and orders").unwrap();
460 for m in &s.models {
461 for f in &m.fields {
462 assert!(
463 VALID_TYPE_NAMES.contains(&f.ty),
464 "field {}.{} has invalid type `{}`",
465 m.struct_name,
466 f.name,
467 f.ty
468 );
469 }
470 }
471 }
472
473 #[test]
474 fn belongs_to_targets_an_earlier_model() {
475 for descr in [
476 "clinic",
477 "blog",
478 "shop with products",
479 "crm with deals",
480 "tasks",
481 ] {
482 let s = sketch(descr).unwrap();
483 let mut seen: Vec<&str> = Vec::new();
484 for m in &s.models {
485 for f in &m.fields {
486 if let Some(target) = f.belongs_to {
487 assert!(
488 seen.contains(&target),
489 "{}.{} → `{}` references a model not yet introduced",
490 m.struct_name,
491 f.name,
492 target
493 );
494 }
495 }
496 seen.push(m.struct_name);
497 }
498 }
499 }
500
501 #[test]
502 fn primitives_for_emits_add_model_then_relations() {
503 let s = sketch("clinic").unwrap();
504 let appointment = s
505 .models
506 .iter()
507 .find(|m| m.struct_name == "Appointment")
508 .unwrap();
509 let ops = primitives_for(appointment);
510 assert!(matches!(ops.first(), Some(Primitive::AddModel(_))));
513 let n_relations = ops
514 .iter()
515 .filter(|p| matches!(p, Primitive::AddRelation(_)))
516 .count();
517 assert_eq!(n_relations, 2);
518 }
519
520 #[test]
521 fn plan_for_full_sketch_validates_against_empty_schema() {
522 use crate::schema::{Schema, SCHEMA_VERSION};
523 let empty = Schema {
527 version: SCHEMA_VERSION,
528 rustio_version: env!("CARGO_PKG_VERSION").to_string(),
529 models: vec![],
530 };
531 let sk = sketch("a small clinic").unwrap();
532 let plan = plan_for(&sk.models);
533 plan.validate(&empty)
537 .expect("clinic sketch should simulate cleanly against empty schema");
538 }
539}