1use serde::{Deserialize, Serialize};
40
41use super::{
42 validate_primitive, AddField, ChangeFieldNullability, ChangeFieldType, FieldSpec, Plan,
43 Primitive, PrimitiveError, RemoveField, RenameField, RenameModel,
44};
45use crate::schema::{Schema, SchemaModel};
46
47#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize)]
71#[serde(default, deny_unknown_fields)]
72pub struct ContextConfig {
73 pub country: Option<String>,
74 pub region: Option<String>,
75 pub industry: Option<String>,
76 #[serde(default)]
77 pub compliance: Vec<String>,
78}
79
80impl ContextConfig {
81 pub fn parse(json: &str) -> Result<Self, PlanError> {
82 serde_json::from_str::<ContextConfig>(json)
83 .map_err(|e| PlanError::ContextParse(e.to_string()))
84 }
85
86 pub fn effective_region(&self) -> Option<String> {
91 if let Some(r) = &self.region {
92 if !r.trim().is_empty() {
93 return Some(r.clone());
94 }
95 }
96 const EU_MEMBER_STATES: &[&str] = &[
97 "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE",
98 "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE",
99 ];
100 match self.country.as_deref() {
101 Some(cc) if EU_MEMBER_STATES.iter().any(|m| m.eq_ignore_ascii_case(cc)) => {
102 Some("EU".into())
103 }
104 _ => None,
105 }
106 }
107
108 pub fn requires_gdpr(&self) -> bool {
112 if self
113 .compliance
114 .iter()
115 .any(|c| c.trim().eq_ignore_ascii_case("GDPR"))
116 {
117 return true;
118 }
119 matches!(self.effective_region().as_deref(), Some("EU"))
120 }
121
122 pub fn industry_schema(&self) -> Option<super::industry::IndustrySchema> {
126 self.industry
127 .as_deref()
128 .and_then(super::industry::industry_schema_for)
129 }
130
131 pub fn pii_fields(&self) -> Vec<&'static str> {
141 let mut out: Vec<&'static str> = Vec::new();
142 match self.country.as_deref() {
143 Some(cc) if cc.eq_ignore_ascii_case("SE") => {
144 out.push("personnummer");
145 }
146 Some(cc) if cc.eq_ignore_ascii_case("NO") => {
147 out.push("fodselsnummer");
148 }
149 Some(cc) if cc.eq_ignore_ascii_case("US") => {
150 out.push("ssn");
151 }
152 _ => {}
153 }
154 if self.requires_gdpr() {
155 for f in ["email", "phone", "address", "date_of_birth"] {
160 if !out.contains(&f) {
161 out.push(f);
162 }
163 }
164 }
165 out
166 }
167
168 pub fn is_empty(&self) -> bool {
172 self.country.is_none()
173 && self.region.is_none()
174 && self.industry.is_none()
175 && self.compliance.is_empty()
176 }
177}
178
179#[derive(Debug, Clone)]
181pub struct PlanRequest {
182 pub prompt: String,
183}
184
185impl PlanRequest {
186 pub fn new<S: Into<String>>(prompt: S) -> Self {
187 Self {
188 prompt: prompt.into(),
189 }
190 }
191}
192
193#[derive(Debug, Clone)]
196pub struct PlanResult {
197 pub plan: Plan,
198 pub explanation: String,
199}
200
201#[non_exhaustive]
204#[derive(Debug, Clone, PartialEq)]
205pub enum PlanError {
206 EmptyPrompt,
208 InvalidIntent(String),
210 UnknownModel { hint: String },
212 AmbiguousModel {
217 hint: String,
218 candidates: Vec<String>,
219 },
220 FieldAlreadyExists { model: String, field: String },
222 FieldDoesNotExist { model: String, field: String },
225 DeveloperOnlyRequested(&'static str),
228 CoreModelProtected(String),
231 UnknownType(String),
233 Validation(PrimitiveError),
237 ContextParse(String),
239}
240
241impl std::fmt::Display for PlanError {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 match self {
244 Self::EmptyPrompt => write!(f, "empty prompt"),
245 Self::InvalidIntent(msg) => write!(f, "invalid intent: {msg}"),
246 Self::UnknownModel { hint } => write!(f, "unknown model `{hint}`"),
247 Self::AmbiguousModel { hint, candidates } => write!(
248 f,
249 "ambiguous model `{hint}` (candidates: {})",
250 candidates.join(", ")
251 ),
252 Self::FieldAlreadyExists { model, field } => {
253 write!(f, "field `{model}.{field}` already exists")
254 }
255 Self::FieldDoesNotExist { model, field } => {
256 write!(f, "field `{model}.{field}` does not exist")
257 }
258 Self::DeveloperOnlyRequested(op) => write!(
259 f,
260 "`{op}` is developer-only and the AI planner cannot emit it"
261 ),
262 Self::CoreModelProtected(name) => write!(
263 f,
264 "model `{name}` is a core model and cannot be modified by the AI planner"
265 ),
266 Self::UnknownType(t) => write!(
267 f,
268 "unknown type `{t}` (valid: i32, i64, String, bool, DateTime)"
269 ),
270 Self::Validation(e) => write!(f, "plan validation failed: {e}"),
271 Self::ContextParse(msg) => write!(f, "rustio.context.json parse error: {msg}"),
272 }
273 }
274}
275
276impl std::error::Error for PlanError {}
277
278pub fn generate_plan(
285 schema: &Schema,
286 context: Option<&ContextConfig>,
287 request: PlanRequest,
288) -> Result<PlanResult, PlanError> {
289 let raw = request.prompt.trim();
290 if raw.is_empty() {
291 return Err(PlanError::EmptyPrompt);
292 }
293 let lower = raw.to_lowercase();
294
295 if lower.contains("create migration")
298 || lower.contains("raw sql")
299 || lower.contains("run sql")
300 || lower.contains("execute sql")
301 || lower.contains("add sql")
302 {
303 return Err(PlanError::DeveloperOnlyRequested("create_migration"));
304 }
305
306 for parser in PARSERS {
309 if let Some(result) = parser(raw, &lower, schema, context)? {
310 result
313 .plan
314 .validate(schema)
315 .map_err(PlanError::Validation)?;
316 return Ok(result);
317 }
318 }
319
320 Err(PlanError::InvalidIntent(supported_forms_message(raw)))
321}
322
323type Parser = fn(
328 raw: &str,
329 lower: &str,
330 schema: &Schema,
331 ctx: Option<&ContextConfig>,
332) -> Result<Option<PlanResult>, PlanError>;
333
334const PARSERS: &[Parser] = &[
335 try_rename_model,
336 try_rename_field,
337 try_remove_field,
338 try_change_type,
339 try_change_nullability,
340 try_add_relation,
344 try_add_field,
345];
346
347fn try_add_relation(
354 raw: &str,
355 lower: &str,
356 schema: &Schema,
357 _context: Option<&ContextConfig>,
358) -> Result<Option<PlanResult>, PlanError> {
359 let after = if let Some(rest) = lower.strip_prefix("add relation from ") {
362 slice_original(raw, "add relation from ").unwrap_or(rest)
363 } else if let Some(rest) = lower.strip_prefix("link ") {
364 slice_original(raw, "link ").unwrap_or(rest)
365 } else if let Some(rest) = lower.strip_prefix("connect ") {
366 slice_original(raw, "connect ").unwrap_or(rest)
367 } else {
368 return Ok(None);
369 };
370
371 let Some((from_hint, to_hint)) = split_on_keyword(after, &[" to "]) else {
372 return Err(PlanError::InvalidIntent(format!(
373 "relation prompt expects `<model> to <target>`: got {raw:?}"
374 )));
375 };
376 let (to_hint, required, on_delete) = parse_relation_options(to_hint, raw)?;
382 let from = resolve_model(schema, from_hint)?;
383 let to = resolve_model(schema, to_hint.as_str())?;
384
385 let via = format!("{}_id", depluralise(&to.admin_name.to_lowercase()));
388
389 if from.fields.iter().any(|f| f.name == via) {
393 return Err(PlanError::FieldAlreadyExists {
394 model: from.name.clone(),
395 field: via,
396 });
397 }
398
399 if from.core {
402 return Err(PlanError::CoreModelProtected(from.name.clone()));
403 }
404 if to.core {
405 return Err(PlanError::CoreModelProtected(to.name.clone()));
406 }
407
408 let primitive = Primitive::AddRelation(super::AddRelation {
409 from: from.name.clone(),
410 kind: crate::schema::RelationKind::BelongsTo,
411 to: to.name.clone(),
412 via: via.clone(),
413 required,
414 on_delete,
415 });
416 validate_primitive(&primitive).map_err(PlanError::Validation)?;
417
418 let nullability = if required { "required" } else { "nullable" };
419 let explanation = format!(
420 "Adds a `belongs_to` relation from `{}` to `{}` via column `{}` \
421 (i64, {nullability}, on_delete:{}). The executor emits a SQL \
422 FOREIGN KEY.",
423 from.name,
424 to.name,
425 via,
426 on_delete.as_str(),
427 );
428 Ok(Some(PlanResult {
429 plan: Plan::new(vec![primitive]),
430 explanation,
431 }))
432}
433
434fn try_add_field(
435 raw: &str,
436 lower: &str,
437 schema: &Schema,
438 context: Option<&ContextConfig>,
439) -> Result<Option<PlanResult>, PlanError> {
440 let Some(rest) = lower.strip_prefix("add ") else {
441 return Ok(None);
442 };
443 if rest.starts_with("model ") {
445 return Err(PlanError::InvalidIntent(
446 "`add model …` is not supported yet by the planner (requires spec of every field). \
447 Write the model by hand and the AI layer will read it from the schema."
448 .to_string(),
449 ));
450 }
451 let after = slice_original(raw, "add ").unwrap_or(raw);
452 let Some((left, right)) = split_on_keyword(after, &[" to ", " on "]) else {
453 return Err(PlanError::InvalidIntent(format!(
454 "`add` requires `… to <model>`: got {raw:?}"
455 )));
456 };
457 let model = resolve_model(schema, right)?;
458 if model.core {
459 return Err(PlanError::CoreModelProtected(model.name.clone()));
460 }
461 let (field_name, modifiers) = parse_field_phrase(left);
462 if field_name.is_empty() {
463 return Err(PlanError::InvalidIntent(
464 "missing field name in `add` clause".to_string(),
465 ));
466 }
467 if model.fields.iter().any(|f| f.name == field_name) {
468 return Err(PlanError::FieldAlreadyExists {
469 model: model.name.clone(),
470 field: field_name,
471 });
472 }
473 let (ty, nullable) = infer_field_type(&field_name, &modifiers, context)?;
474 let nullable = nullable || phrase_is_optional(&modifiers);
475
476 let primitive = Primitive::AddField(AddField {
477 model: model.name.clone(),
478 field: FieldSpec {
479 name: field_name.clone(),
480 ty: ty.clone(),
481 nullable,
482 editable: true,
483 },
484 });
485 validate_primitive(&primitive).map_err(PlanError::Validation)?;
486 let explanation = explain_add_field(&model.name, &field_name, &ty, nullable, context);
487 Ok(Some(PlanResult {
488 plan: Plan::new(vec![primitive]),
489 explanation,
490 }))
491}
492
493fn try_rename_field(
494 raw: &str,
495 lower: &str,
496 schema: &Schema,
497 _context: Option<&ContextConfig>,
498) -> Result<Option<PlanResult>, PlanError> {
499 let Some(rest) = lower.strip_prefix("rename ") else {
500 return Ok(None);
501 };
502 if rest.starts_with("model ") {
504 return Ok(None);
505 }
506 if !rest.contains(" in ") {
507 return Ok(None);
508 }
509 let after = slice_original(raw, "rename ").unwrap_or(raw);
510 let Some((lhs, model_hint)) = split_on_keyword(after, &[" in "]) else {
511 return Ok(None);
512 };
513 let Some((from, to)) = split_on_keyword(lhs, &[" to ", " -> "]) else {
514 return Err(PlanError::InvalidIntent(format!(
515 "`rename <field> to <new> in <model>` expected: got {raw:?}"
516 )));
517 };
518 let model = resolve_model(schema, model_hint)?;
519 if model.core {
520 return Err(PlanError::CoreModelProtected(model.name.clone()));
521 }
522 let from_name = sanitise_identifier(from);
523 let to_name = sanitise_identifier(to);
524 if from_name.is_empty() || to_name.is_empty() {
525 return Err(PlanError::InvalidIntent(
526 "rename clause is missing a name on one side".to_string(),
527 ));
528 }
529 if !model.fields.iter().any(|f| f.name == from_name) {
530 return Err(PlanError::FieldDoesNotExist {
531 model: model.name.clone(),
532 field: from_name,
533 });
534 }
535 if model.fields.iter().any(|f| f.name == to_name) {
536 return Err(PlanError::FieldAlreadyExists {
537 model: model.name.clone(),
538 field: to_name,
539 });
540 }
541 let primitive = Primitive::RenameField(RenameField {
542 model: model.name.clone(),
543 from: from_name.clone(),
544 to: to_name.clone(),
545 });
546 validate_primitive(&primitive).map_err(PlanError::Validation)?;
547 let explanation = format!(
548 "Renames field `{from_name}` to `{to_name}` on model `{model}` \
549 (data-preserving — the underlying column is renamed, not dropped).",
550 model = model.name,
551 );
552 Ok(Some(PlanResult {
553 plan: Plan::new(vec![primitive]),
554 explanation,
555 }))
556}
557
558fn try_rename_model(
559 raw: &str,
560 lower: &str,
561 schema: &Schema,
562 _context: Option<&ContextConfig>,
563) -> Result<Option<PlanResult>, PlanError> {
564 let prefix = if let Some(r) = lower.strip_prefix("rename model ") {
565 r
566 } else {
567 return Ok(None);
568 };
569 let after = slice_original(raw, "rename model ").unwrap_or(prefix);
570 let Some((from, to)) = split_on_keyword(after, &[" to ", " -> "]) else {
571 return Err(PlanError::InvalidIntent(format!(
572 "`rename model <from> to <to>` expected: got {raw:?}"
573 )));
574 };
575 let model = resolve_model(schema, from)?;
576 if model.core {
577 return Err(PlanError::CoreModelProtected(model.name.clone()));
578 }
579 let to_name = pascalise(to.trim());
580 if to_name.is_empty() {
581 return Err(PlanError::InvalidIntent(
582 "new model name is empty".to_string(),
583 ));
584 }
585 if schema.models.iter().any(|m| m.name == to_name) {
586 return Err(PlanError::InvalidIntent(format!(
587 "a model named `{to_name}` already exists"
588 )));
589 }
590 let from_name = model.name.clone();
591 let primitive = Primitive::RenameModel(RenameModel {
592 from: from_name.clone(),
593 to: to_name.clone(),
594 });
595 validate_primitive(&primitive).map_err(PlanError::Validation)?;
596 let explanation = format!(
597 "Renames model `{from_name}` to `{to_name}`. Table is renamed in \
598 place — existing rows are preserved."
599 );
600 Ok(Some(PlanResult {
601 plan: Plan::new(vec![primitive]),
602 explanation,
603 }))
604}
605
606fn try_remove_field(
607 raw: &str,
608 lower: &str,
609 schema: &Schema,
610 _context: Option<&ContextConfig>,
611) -> Result<Option<PlanResult>, PlanError> {
612 let (prefix, original_prefix) = if lower.starts_with("remove ") {
613 ("remove ", "remove ")
614 } else if lower.starts_with("drop ") {
615 ("drop ", "drop ")
616 } else if lower.starts_with("delete ") {
617 ("delete ", "delete ")
618 } else {
619 return Ok(None);
620 };
621 let body = &lower[prefix.len()..];
623 if body.starts_with("model ") {
624 return Err(PlanError::DeveloperOnlyRequested("remove_model"));
625 }
626 let after = slice_original(raw, original_prefix).unwrap_or(raw);
627 let Some((field_phrase, model_hint)) = split_on_keyword(after, &[" from ", " on "]) else {
628 return Err(PlanError::InvalidIntent(format!(
629 "`{prefix}<field> from <model>` expected: got {raw:?}",
630 prefix = prefix.trim_end(),
631 )));
632 };
633 let model = resolve_model(schema, model_hint)?;
634 if model.core {
635 return Err(PlanError::CoreModelProtected(model.name.clone()));
636 }
637 let (field_name, _) = parse_field_phrase(field_phrase);
638 if !model.fields.iter().any(|f| f.name == field_name) {
639 return Err(PlanError::FieldDoesNotExist {
640 model: model.name.clone(),
641 field: field_name,
642 });
643 }
644 let primitive = Primitive::RemoveField(RemoveField {
645 model: model.name.clone(),
646 field: field_name.clone(),
647 });
648 validate_primitive(&primitive).map_err(PlanError::Validation)?;
649 let explanation = format!(
650 "Removes field `{field_name}` from model `{model}`. The underlying column \
651 is dropped; review data before applying.",
652 model = model.name,
653 );
654 Ok(Some(PlanResult {
655 plan: Plan::new(vec![primitive]),
656 explanation,
657 }))
658}
659
660fn try_change_type(
661 raw: &str,
662 lower: &str,
663 schema: &Schema,
664 _context: Option<&ContextConfig>,
665) -> Result<Option<PlanResult>, PlanError> {
666 let Some(rest) = lower.strip_prefix("change ") else {
667 return Ok(None);
668 };
669 let after = slice_original(raw, "change ").unwrap_or(rest);
670 let Some((lhs, new_type_hint)) = split_on_keyword(after, &[" to "]) else {
672 return Ok(None);
673 };
674 let Some((field_phrase, model_hint)) = split_on_keyword(lhs, &[" in ", " on "]) else {
675 return Ok(None);
676 };
677 let model = resolve_model(schema, model_hint)?;
678 if model.core {
679 return Err(PlanError::CoreModelProtected(model.name.clone()));
680 }
681 let (field_name, _) = parse_field_phrase(field_phrase);
682 if !model.fields.iter().any(|f| f.name == field_name) {
683 return Err(PlanError::FieldDoesNotExist {
684 model: model.name.clone(),
685 field: field_name,
686 });
687 }
688 let ty = normalise_type_hint(new_type_hint.trim())?;
689 let primitive = Primitive::ChangeFieldType(ChangeFieldType {
690 model: model.name.clone(),
691 field: field_name.clone(),
692 new_type: ty.clone(),
693 });
694 validate_primitive(&primitive).map_err(PlanError::Validation)?;
695 let explanation = format!(
696 "Changes type of `{model}.{field_name}` to `{ty}`. The executor (0.5.x) \
697 will refuse lossy conversions at apply time.",
698 model = model.name,
699 );
700 Ok(Some(PlanResult {
701 plan: Plan::new(vec![primitive]),
702 explanation,
703 }))
704}
705
706fn try_change_nullability(
707 raw: &str,
708 lower: &str,
709 schema: &Schema,
710 _context: Option<&ContextConfig>,
711) -> Result<Option<PlanResult>, PlanError> {
712 let Some(rest) = lower.strip_prefix("make ") else {
713 return Ok(None);
714 };
715 let after = slice_original(raw, "make ").unwrap_or(rest);
716 let Some((field_phrase, rest_phrase)) = split_on_keyword(after, &[" in ", " on "]) else {
717 return Ok(None);
718 };
719 let rest_lower = rest_phrase.to_lowercase();
720 let mut nullable_hint: Option<bool> = None;
721 for (needle, target) in [
722 (" optional", true),
723 (" nullable", true),
724 (" required", false),
725 (" not null", false),
726 (" non-null", false),
727 ] {
728 if rest_lower.contains(needle) {
729 nullable_hint = Some(target);
730 break;
731 }
732 }
733 let Some(nullable) = nullable_hint else {
736 return Ok(None);
737 };
738 let model_hint = strip_known_modifiers(rest_phrase);
739 let model = resolve_model(schema, &model_hint)?;
740 if model.core {
741 return Err(PlanError::CoreModelProtected(model.name.clone()));
742 }
743 let (field_name, _) = parse_field_phrase(field_phrase);
744 if !model.fields.iter().any(|f| f.name == field_name) {
745 return Err(PlanError::FieldDoesNotExist {
746 model: model.name.clone(),
747 field: field_name,
748 });
749 }
750 let primitive = Primitive::ChangeFieldNullability(ChangeFieldNullability {
751 model: model.name.clone(),
752 field: field_name.clone(),
753 nullable,
754 });
755 validate_primitive(&primitive).map_err(PlanError::Validation)?;
756 let explanation = format!(
757 "Marks `{model}.{field_name}` as {state}.",
758 model = model.name,
759 state = if nullable { "nullable" } else { "required" },
760 );
761 Ok(Some(PlanResult {
762 plan: Plan::new(vec![primitive]),
763 explanation,
764 }))
765}
766
767fn resolve_model<'a>(schema: &'a Schema, hint: &str) -> Result<&'a SchemaModel, PlanError> {
776 let h = sanitise_identifier(hint).to_lowercase();
777 if h.is_empty() {
778 return Err(PlanError::UnknownModel {
779 hint: hint.trim().to_string(),
780 });
781 }
782 let h_singular = depluralise(&h);
783 let h_plural = pluralise(&h);
784 let mut matches: Vec<&SchemaModel> = schema
785 .models
786 .iter()
787 .filter(|m| {
788 let forms = [
789 m.name.to_lowercase(),
790 m.table.to_lowercase(),
791 m.admin_name.to_lowercase(),
792 m.singular_name.to_lowercase(),
793 ];
794 forms
795 .iter()
796 .any(|f| f == &h || f == &h_singular || f == &h_plural)
797 })
798 .collect();
799 matches.dedup_by(|a, b| a.name == b.name);
801 match matches.len() {
802 0 => Err(PlanError::UnknownModel {
803 hint: hint.trim().to_string(),
804 }),
805 1 => Ok(matches[0]),
806 _ => Err(PlanError::AmbiguousModel {
807 hint: hint.trim().to_string(),
808 candidates: matches.iter().map(|m| m.name.clone()).collect(),
809 }),
810 }
811}
812
813fn parse_field_phrase(phrase: &str) -> (String, String) {
818 let remainder = phrase.to_string();
819 let stripped = strip_known_modifiers(phrase);
820 let no_as = match split_on_keyword(&stripped, &[" as ", ":"]) {
821 Some((left, _)) => left.to_string(),
822 None => stripped,
823 };
824 let name = no_as
826 .split_whitespace()
827 .map(|w| {
828 w.trim_matches(|c: char| !c.is_alphanumeric() && c != '_')
829 .to_lowercase()
830 })
831 .filter(|w| !w.is_empty())
832 .collect::<Vec<_>>()
833 .join("_");
834 (name, remainder)
835}
836
837fn strip_known_modifiers(phrase: &str) -> String {
840 let tokens = phrase.split_whitespace().filter(|t| {
841 !matches!(
842 t.to_lowercase().as_str(),
843 "a" | "an"
844 | "the"
845 | "optional"
846 | "nullable"
847 | "required"
848 | "new"
849 | "field"
850 | "column"
851 | "to"
852 | "add"
853 )
854 });
855 tokens.collect::<Vec<_>>().join(" ")
856}
857
858fn phrase_is_optional(phrase: &str) -> bool {
859 let l = phrase.to_lowercase();
860 l.split_whitespace()
861 .any(|w| w == "optional" || w == "nullable")
862}
863
864fn infer_field_type(
872 name: &str,
873 phrase: &str,
874 context: Option<&ContextConfig>,
875) -> Result<(String, bool), PlanError> {
876 let lower = phrase.to_lowercase();
877 if let Some((_, after)) = split_on_keyword(phrase, &[" as ", ":"]) {
879 let ty = normalise_type_hint(after.trim_end_matches('.').trim())?;
880 return Ok((ty, phrase_is_optional(phrase)));
881 }
882 for (needle, mapped) in [
884 ("datetime", "DateTime"),
885 ("timestamp", "DateTime"),
886 ("boolean", "bool"),
887 ("integer", "i32"),
888 ("number", "i32"),
889 ("string", "String"),
890 ("text", "String"),
891 ] {
892 if lower.split_whitespace().any(|w| w == needle) {
893 return Ok((mapped.to_string(), phrase_is_optional(phrase)));
894 }
895 }
896
897 if let Some(ctx) = context {
901 let n = name.to_lowercase();
902 if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("SE"))
904 && (n == "personnummer" || n == "personal_number" || n == "personal_id" || n == "pnr")
905 {
906 return Ok(("String".to_string(), false));
907 }
908 if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("NO"))
909 && (n == "fodselsnummer" || n == "personal_number" || n == "personal_id")
910 {
911 return Ok(("String".to_string(), false));
912 }
913 let industry = ctx.industry.as_deref();
920 let is_healthcare = industry.is_some_and(|i| i.eq_ignore_ascii_case("healthcare"));
921 let is_banking = industry.is_some_and(|i| i.eq_ignore_ascii_case("banking"));
922 if is_healthcare
923 && (n == "patient_id"
924 || n == "patient"
925 || n.ends_with("_patient_id")
926 || n == "medical_record_number"
927 || n == "mrn")
928 {
929 return Ok(("String".to_string(), false));
930 }
931 if is_banking && (n == "account_number" || n == "iban" || n == "bic") {
932 return Ok(("String".to_string(), false));
933 }
934 if is_banking
935 && (n == "balance"
936 || n == "amount"
937 || n.ends_with("_amount")
938 || n.ends_with("_balance"))
939 {
940 return Ok(("i64".to_string(), phrase_is_optional(phrase)));
941 }
942 }
943
944 let n = name.to_lowercase();
946 let nullable = phrase_is_optional(phrase);
947 if n.ends_with("_at")
948 || n.ends_with("_on")
949 || n.ends_with("_date")
950 || n == "created_at"
951 || n == "updated_at"
952 || n == "deleted_at"
953 || n.ends_with("_time")
954 || n == "timestamp"
955 {
956 return Ok(("DateTime".to_string(), nullable));
957 }
958 if n.starts_with("is_")
959 || n.starts_with("has_")
960 || n == "active"
961 || n == "enabled"
962 || n == "archived"
963 {
964 return Ok(("bool".to_string(), nullable));
965 }
966 if n == "priority"
967 || n == "count"
968 || n == "score"
969 || n == "rank"
970 || n == "quantity"
971 || n == "age"
972 || n.ends_with("_count")
973 || n.ends_with("_id")
974 {
975 return Ok(("i32".to_string(), nullable));
976 }
977 if n == "price"
984 || n == "balance"
985 || n == "amount"
986 || n.ends_with("_income")
987 || n.ends_with("_amount")
988 || n.ends_with("_total")
989 || n.ends_with("_price")
990 {
991 return Ok(("i64".to_string(), nullable));
992 }
993 Ok(("String".to_string(), nullable))
995}
996
997fn normalise_type_hint(raw: &str) -> Result<String, PlanError> {
999 let r = raw.trim().trim_matches('`').to_lowercase();
1000 let r = r.trim_start_matches("type ").trim();
1001 match r {
1002 "i32" | "int" | "integer" | "number" | "int32" => Ok("i32".to_string()),
1003 "i64" | "long" | "bigint" | "int64" => Ok("i64".to_string()),
1004 "string" | "text" | "str" | "varchar" => Ok("String".to_string()),
1005 "bool" | "boolean" | "flag" => Ok("bool".to_string()),
1006 "datetime" | "timestamp" | "date" | "time" | "datetime<utc>" => Ok("DateTime".to_string()),
1007 _ => Err(PlanError::UnknownType(raw.to_string())),
1008 }
1009}
1010
1011fn slice_original<'a>(raw: &'a str, prefix_lower: &str) -> Option<&'a str> {
1015 if raw.len() < prefix_lower.len() {
1016 return None;
1017 }
1018 let head = &raw[..prefix_lower.len()];
1019 if head.to_lowercase() != prefix_lower {
1020 return None;
1021 }
1022 Some(&raw[prefix_lower.len()..])
1023}
1024
1025fn parse_relation_options(
1036 to_hint: &str,
1037 original_prompt: &str,
1038) -> Result<(String, bool, super::OnDelete), PlanError> {
1039 let mut parts = to_hint.split_whitespace();
1040 let target = parts.next().ok_or_else(|| {
1041 PlanError::InvalidIntent(format!(
1042 "relation prompt missing a target model: {original_prompt:?}"
1043 ))
1044 })?;
1045
1046 let mut required = false;
1047 let mut on_delete = super::OnDelete::Restrict;
1048 for token in parts {
1049 match token.to_ascii_lowercase().as_str() {
1050 "required" => required = true,
1051 "optional" => required = false,
1052 t if t.starts_with("on_delete:") => {
1053 on_delete = match &t["on_delete:".len()..] {
1054 "restrict" => super::OnDelete::Restrict,
1055 "cascade" => super::OnDelete::Cascade,
1056 "set_null" | "setnull" => super::OnDelete::SetNull,
1057 other => {
1058 return Err(PlanError::InvalidIntent(format!(
1059 "unknown on_delete policy `{other}` (want restrict|cascade|set_null): {original_prompt:?}"
1060 )));
1061 }
1062 };
1063 }
1064 other => {
1065 return Err(PlanError::InvalidIntent(format!(
1066 "unknown relation option `{other}` (want required|optional|on_delete:<policy>): {original_prompt:?}"
1067 )));
1068 }
1069 }
1070 }
1071
1072 Ok((target.to_string(), required, on_delete))
1073}
1074
1075fn split_on_keyword<'a>(raw: &'a str, keywords: &[&str]) -> Option<(&'a str, &'a str)> {
1078 let lower = raw.to_lowercase();
1079 let mut best: Option<(usize, usize)> = None;
1080 for kw in keywords {
1081 if let Some(idx) = lower.find(kw) {
1082 match best {
1083 Some((best_idx, _)) if best_idx <= idx => {}
1084 _ => best = Some((idx, kw.len())),
1085 }
1086 }
1087 }
1088 let (idx, kw_len) = best?;
1089 let left = raw[..idx].trim();
1090 let right = raw[idx + kw_len..].trim();
1091 Some((left, right))
1092}
1093
1094fn sanitise_identifier(raw: &str) -> String {
1095 raw.trim()
1096 .trim_matches(|c: char| c == '`' || c == '"' || c == '\'' || c == '.' || c == ',')
1097 .to_string()
1098}
1099
1100fn pluralise(name: &str) -> String {
1101 if name.ends_with('s') {
1102 return name.to_string();
1103 }
1104 if name.ends_with('y') && name.len() > 1 {
1105 let mut out = String::from(&name[..name.len() - 1]);
1106 out.push_str("ies");
1107 return out;
1108 }
1109 format!("{name}s")
1110}
1111
1112fn depluralise(name: &str) -> String {
1113 if let Some(stripped) = name.strip_suffix("ies") {
1114 let mut out = String::from(stripped);
1115 out.push('y');
1116 return out;
1117 }
1118 if let Some(stripped) = name.strip_suffix('s') {
1119 return stripped.to_string();
1120 }
1121 name.to_string()
1122}
1123
1124fn pascalise(raw: &str) -> String {
1126 let mut out = String::new();
1127 let mut next_upper = true;
1128 for ch in raw.chars() {
1129 if ch == '_' || ch == '-' || ch.is_whitespace() {
1130 next_upper = true;
1131 continue;
1132 }
1133 if !ch.is_alphanumeric() {
1134 continue;
1135 }
1136 if next_upper {
1137 out.extend(ch.to_uppercase());
1138 next_upper = false;
1139 } else {
1140 out.extend(ch.to_lowercase());
1141 }
1142 }
1143 out
1144}
1145
1146fn explain_add_field(
1147 model: &str,
1148 field: &str,
1149 ty: &str,
1150 nullable: bool,
1151 context: Option<&ContextConfig>,
1152) -> String {
1153 let opt = if nullable { ", nullable" } else { "" };
1154 let head = format!("Adds field `{field}` ({ty}{opt}) to model `{model}`.");
1155 let rationale = match (ty, field) {
1156 ("DateTime", _) => {
1157 " Stored as ISO-8601 UTC; the admin renders it as a datetime-local input."
1158 }
1159 ("bool", _) => " Rendered as a checkbox in the admin and a pill on list pages.",
1160 ("i32", f) if f == "priority" || f == "score" || f == "rank" => {
1161 " Useful for sorting and filtering records by importance."
1162 }
1163 ("i32", _) => " Numeric — the list view shows it with tabular numerics.",
1164 ("String", "status") => " Status values get coloured pills in list views.",
1165 _ => "",
1166 };
1167 let mut tail = String::new();
1168 if let Some(ctx) = context {
1169 if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("SE"))
1173 && matches!(
1174 field,
1175 "personnummer" | "personal_id" | "personal_number" | "pnr"
1176 )
1177 {
1178 tail.push_str(
1179 " Swedish personnummer is stored as a 13-character string (YYYYMMDD-XXXX).",
1180 );
1181 }
1182 match ctx.industry.as_deref() {
1184 Some(i)
1185 if i.eq_ignore_ascii_case("healthcare")
1186 && (field == "patient_id"
1187 || field == "mrn"
1188 || field == "medical_record_number") =>
1189 {
1190 tail.push_str(
1191 " Patient identifiers are opaque strings (UUID or hash); sequential integers would leak enrolment order.",
1192 );
1193 }
1194 Some(i)
1195 if i.eq_ignore_ascii_case("banking")
1196 && (field == "balance" || field == "amount" || field.ends_with("_amount")) =>
1197 {
1198 tail.push_str(
1199 " Monetary values are stored as integer minor units (öre, cents). Never use floats.",
1200 );
1201 }
1202 _ => {}
1203 }
1204 if ctx.requires_gdpr() && is_generic_pii_field(field) {
1206 tail.push_str(
1207 " Under GDPR this field is personal data — retention and right-to-erasure rules apply.",
1208 );
1209 }
1210 }
1211 format!("{head}{rationale}{tail}")
1212}
1213
1214fn is_generic_pii_field(name: &str) -> bool {
1215 matches!(
1216 name,
1217 "email" | "phone" | "address" | "date_of_birth" | "ssn" | "personnummer" | "fodselsnummer"
1218 )
1219}
1220
1221fn supported_forms_message(raw: &str) -> String {
1222 format!(
1223 "could not interpret prompt {raw:?}. Supported forms:\n \
1224 - add <field> to <model>\n \
1225 - add <field> as <type> to <model>\n \
1226 - add optional <field> to <model>\n \
1227 - rename <field> to <new> in <model>\n \
1228 - rename model <from> to <to>\n \
1229 - remove <field> from <model>\n \
1230 - change <field> in <model> to <type>\n \
1231 - make <field> in <model> optional|required"
1232 )
1233}
1234
1235pub fn render_plan_json(plan: &Plan, explanation: &str) -> String {
1249 let steps: Vec<serde_json::Value> = plan.steps.iter().map(primitive_to_cli_json).collect();
1250 let out = serde_json::json!({
1251 "plan": steps,
1252 "explanation": explanation,
1253 });
1254 serde_json::to_string_pretty(&out).unwrap_or_else(|_| "{}".to_string())
1255}
1256
1257pub fn render_plan_human(plan: &Plan, explanation: &str) -> String {
1260 let mut out = String::from("Plan:\n");
1261 if plan.steps.is_empty() {
1262 out.push_str(" (no changes)\n");
1263 }
1264 for step in &plan.steps {
1265 out.push_str(" - ");
1266 out.push_str(&summarise_primitive(step));
1267 out.push('\n');
1268 }
1269 out.push_str("\nExplanation:\n");
1270 out.push_str(explanation);
1271 if !explanation.ends_with('\n') {
1272 out.push('\n');
1273 }
1274 out
1275}
1276
1277fn primitive_to_cli_json(p: &Primitive) -> serde_json::Value {
1278 use serde_json::json;
1279 match p {
1280 Primitive::AddField(a) => json!({
1281 "op": "AddField",
1282 "model": a.model,
1283 "field": a.field.name,
1284 "type": a.field.ty,
1285 "nullable": a.field.nullable,
1286 }),
1287 Primitive::RemoveField(r) => json!({
1288 "op": "RemoveField",
1289 "model": r.model,
1290 "field": r.field,
1291 }),
1292 Primitive::RenameField(r) => json!({
1293 "op": "RenameField",
1294 "model": r.model,
1295 "from": r.from,
1296 "to": r.to,
1297 }),
1298 Primitive::RenameModel(r) => json!({
1299 "op": "RenameModel",
1300 "from": r.from,
1301 "to": r.to,
1302 }),
1303 Primitive::ChangeFieldType(c) => json!({
1304 "op": "ChangeFieldType",
1305 "model": c.model,
1306 "field": c.field,
1307 "type": c.new_type,
1308 }),
1309 Primitive::ChangeFieldNullability(c) => json!({
1310 "op": "ChangeFieldNullability",
1311 "model": c.model,
1312 "field": c.field,
1313 "nullable": c.nullable,
1314 }),
1315 Primitive::AddModel(m) => json!({
1316 "op": "AddModel",
1317 "name": m.name,
1318 "table": m.table,
1319 "fields": m.fields.iter().map(|f| json!({
1320 "name": f.name,
1321 "type": f.ty,
1322 "nullable": f.nullable,
1323 })).collect::<Vec<_>>(),
1324 }),
1325 Primitive::RemoveModel(m) => json!({
1326 "op": "RemoveModel",
1327 "name": m.name,
1328 }),
1329 Primitive::AddRelation(r) => json!({
1330 "op": "AddRelation",
1331 "from": r.from,
1332 "kind": format!("{:?}", r.kind).to_lowercase(),
1333 "to": r.to,
1334 "via": r.via,
1335 }),
1336 Primitive::RemoveRelation(r) => json!({
1337 "op": "RemoveRelation",
1338 "from": r.from,
1339 "via": r.via,
1340 }),
1341 Primitive::UpdateAdmin(u) => json!({
1342 "op": "UpdateAdmin",
1343 "model": u.model,
1344 "field": u.field,
1345 "attr": u.attr,
1346 "value": u.value,
1347 }),
1348 Primitive::CreateMigration(_) => {
1349 json!({"op": "CreateMigration", "note": "developer-only"})
1352 }
1353 }
1354}
1355
1356fn summarise_primitive(p: &Primitive) -> String {
1357 match p {
1358 Primitive::AddField(a) => format!(
1359 "Add field \"{}\" ({}{}) to model \"{}\"",
1360 a.field.name,
1361 a.field.ty,
1362 if a.field.nullable { ", nullable" } else { "" },
1363 a.model,
1364 ),
1365 Primitive::RemoveField(r) => {
1366 format!("Remove field \"{}\" from model \"{}\"", r.field, r.model)
1367 }
1368 Primitive::RenameField(r) => {
1369 format!("Rename field \"{}.{}\" to \"{}\"", r.model, r.from, r.to)
1370 }
1371 Primitive::RenameModel(r) => {
1372 format!("Rename model \"{}\" to \"{}\"", r.from, r.to)
1373 }
1374 Primitive::ChangeFieldType(c) => format!(
1375 "Change type of \"{}.{}\" to {}",
1376 c.model, c.field, c.new_type
1377 ),
1378 Primitive::ChangeFieldNullability(c) => format!(
1379 "Mark \"{}.{}\" as {}",
1380 c.model,
1381 c.field,
1382 if c.nullable { "nullable" } else { "required" },
1383 ),
1384 Primitive::AddModel(m) => format!(
1385 "Add model \"{}\" with {} field{}",
1386 m.name,
1387 m.fields.len(),
1388 if m.fields.len() == 1 { "" } else { "s" }
1389 ),
1390 Primitive::RemoveModel(m) => format!("Remove model \"{}\"", m.name),
1391 Primitive::AddRelation(r) => {
1392 format!("Add relation {:?}: {}.{} → {}", r.kind, r.from, r.via, r.to)
1393 }
1394 Primitive::RemoveRelation(r) => format!("Remove relation {}.{}", r.from, r.via),
1395 Primitive::UpdateAdmin(u) => {
1396 format!("Update admin attr \"{}.{}\".{}", u.model, u.field, u.attr)
1397 }
1398 Primitive::CreateMigration(m) => format!("[dev-only] create_migration \"{}\"", m.name),
1399 }
1400}