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 match ctx.industry.as_deref() {
915 Some(i) if i.eq_ignore_ascii_case("healthcare") => {
916 if n == "patient_id"
920 || n == "patient"
921 || n.ends_with("_patient_id")
922 || n == "medical_record_number"
923 || n == "mrn"
924 {
925 return Ok(("String".to_string(), false));
926 }
927 }
928 Some(i) if i.eq_ignore_ascii_case("banking") => {
929 if n == "account_number" || n == "iban" || n == "bic" {
933 return Ok(("String".to_string(), false));
934 }
935 if 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 }
945 }
946
947 let n = name.to_lowercase();
949 let nullable = phrase_is_optional(phrase);
950 if n.ends_with("_at")
951 || n.ends_with("_on")
952 || n.ends_with("_date")
953 || n == "created_at"
954 || n == "updated_at"
955 || n == "deleted_at"
956 || n.ends_with("_time")
957 || n == "timestamp"
958 {
959 return Ok(("DateTime".to_string(), nullable));
960 }
961 if n.starts_with("is_")
962 || n.starts_with("has_")
963 || n == "active"
964 || n == "enabled"
965 || n == "archived"
966 {
967 return Ok(("bool".to_string(), nullable));
968 }
969 if n == "priority"
970 || n == "count"
971 || n == "score"
972 || n == "rank"
973 || n == "quantity"
974 || n == "age"
975 || n.ends_with("_count")
976 || n.ends_with("_id")
977 {
978 return Ok(("i32".to_string(), nullable));
979 }
980 if n == "price"
987 || n == "balance"
988 || n == "amount"
989 || n.ends_with("_income")
990 || n.ends_with("_amount")
991 || n.ends_with("_total")
992 || n.ends_with("_price")
993 {
994 return Ok(("i64".to_string(), nullable));
995 }
996 Ok(("String".to_string(), nullable))
998}
999
1000fn normalise_type_hint(raw: &str) -> Result<String, PlanError> {
1002 let r = raw.trim().trim_matches('`').to_lowercase();
1003 let r = r.trim_start_matches("type ").trim();
1004 match r {
1005 "i32" | "int" | "integer" | "number" | "int32" => Ok("i32".to_string()),
1006 "i64" | "long" | "bigint" | "int64" => Ok("i64".to_string()),
1007 "string" | "text" | "str" | "varchar" => Ok("String".to_string()),
1008 "bool" | "boolean" | "flag" => Ok("bool".to_string()),
1009 "datetime" | "timestamp" | "date" | "time" | "datetime<utc>" => Ok("DateTime".to_string()),
1010 _ => Err(PlanError::UnknownType(raw.to_string())),
1011 }
1012}
1013
1014fn slice_original<'a>(raw: &'a str, prefix_lower: &str) -> Option<&'a str> {
1018 if raw.len() < prefix_lower.len() {
1019 return None;
1020 }
1021 let head = &raw[..prefix_lower.len()];
1022 if head.to_lowercase() != prefix_lower {
1023 return None;
1024 }
1025 Some(&raw[prefix_lower.len()..])
1026}
1027
1028fn parse_relation_options(
1039 to_hint: &str,
1040 original_prompt: &str,
1041) -> Result<(String, bool, super::OnDelete), PlanError> {
1042 let mut parts = to_hint.split_whitespace();
1043 let target = parts.next().ok_or_else(|| {
1044 PlanError::InvalidIntent(format!(
1045 "relation prompt missing a target model: {original_prompt:?}"
1046 ))
1047 })?;
1048
1049 let mut required = false;
1050 let mut on_delete = super::OnDelete::Restrict;
1051 for token in parts {
1052 match token.to_ascii_lowercase().as_str() {
1053 "required" => required = true,
1054 "optional" => required = false,
1055 t if t.starts_with("on_delete:") => {
1056 on_delete = match &t["on_delete:".len()..] {
1057 "restrict" => super::OnDelete::Restrict,
1058 "cascade" => super::OnDelete::Cascade,
1059 "set_null" | "setnull" => super::OnDelete::SetNull,
1060 other => {
1061 return Err(PlanError::InvalidIntent(format!(
1062 "unknown on_delete policy `{other}` (want restrict|cascade|set_null): {original_prompt:?}"
1063 )));
1064 }
1065 };
1066 }
1067 other => {
1068 return Err(PlanError::InvalidIntent(format!(
1069 "unknown relation option `{other}` (want required|optional|on_delete:<policy>): {original_prompt:?}"
1070 )));
1071 }
1072 }
1073 }
1074
1075 Ok((target.to_string(), required, on_delete))
1076}
1077
1078fn split_on_keyword<'a>(raw: &'a str, keywords: &[&str]) -> Option<(&'a str, &'a str)> {
1081 let lower = raw.to_lowercase();
1082 let mut best: Option<(usize, usize)> = None;
1083 for kw in keywords {
1084 if let Some(idx) = lower.find(kw) {
1085 match best {
1086 Some((best_idx, _)) if best_idx <= idx => {}
1087 _ => best = Some((idx, kw.len())),
1088 }
1089 }
1090 }
1091 let (idx, kw_len) = best?;
1092 let left = raw[..idx].trim();
1093 let right = raw[idx + kw_len..].trim();
1094 Some((left, right))
1095}
1096
1097fn sanitise_identifier(raw: &str) -> String {
1098 raw.trim()
1099 .trim_matches(|c: char| c == '`' || c == '"' || c == '\'' || c == '.' || c == ',')
1100 .to_string()
1101}
1102
1103fn pluralise(name: &str) -> String {
1104 if name.ends_with('s') {
1105 return name.to_string();
1106 }
1107 if name.ends_with('y') && name.len() > 1 {
1108 let mut out = String::from(&name[..name.len() - 1]);
1109 out.push_str("ies");
1110 return out;
1111 }
1112 format!("{name}s")
1113}
1114
1115fn depluralise(name: &str) -> String {
1116 if let Some(stripped) = name.strip_suffix("ies") {
1117 let mut out = String::from(stripped);
1118 out.push('y');
1119 return out;
1120 }
1121 if let Some(stripped) = name.strip_suffix('s') {
1122 return stripped.to_string();
1123 }
1124 name.to_string()
1125}
1126
1127fn pascalise(raw: &str) -> String {
1129 let mut out = String::new();
1130 let mut next_upper = true;
1131 for ch in raw.chars() {
1132 if ch == '_' || ch == '-' || ch.is_whitespace() {
1133 next_upper = true;
1134 continue;
1135 }
1136 if !ch.is_alphanumeric() {
1137 continue;
1138 }
1139 if next_upper {
1140 out.extend(ch.to_uppercase());
1141 next_upper = false;
1142 } else {
1143 out.extend(ch.to_lowercase());
1144 }
1145 }
1146 out
1147}
1148
1149fn explain_add_field(
1150 model: &str,
1151 field: &str,
1152 ty: &str,
1153 nullable: bool,
1154 context: Option<&ContextConfig>,
1155) -> String {
1156 let opt = if nullable { ", nullable" } else { "" };
1157 let head = format!("Adds field `{field}` ({ty}{opt}) to model `{model}`.");
1158 let rationale = match (ty, field) {
1159 ("DateTime", _) => {
1160 " Stored as ISO-8601 UTC; the admin renders it as a datetime-local input."
1161 }
1162 ("bool", _) => " Rendered as a checkbox in the admin and a pill on list pages.",
1163 ("i32", f) if f == "priority" || f == "score" || f == "rank" => {
1164 " Useful for sorting and filtering records by importance."
1165 }
1166 ("i32", _) => " Numeric — the list view shows it with tabular numerics.",
1167 ("String", "status") => " Status values get coloured pills in list views.",
1168 _ => "",
1169 };
1170 let mut tail = String::new();
1171 if let Some(ctx) = context {
1172 if matches!(ctx.country.as_deref(), Some(cc) if cc.eq_ignore_ascii_case("SE"))
1176 && matches!(
1177 field,
1178 "personnummer" | "personal_id" | "personal_number" | "pnr"
1179 )
1180 {
1181 tail.push_str(
1182 " Swedish personnummer is stored as a 13-character string (YYYYMMDD-XXXX).",
1183 );
1184 }
1185 match ctx.industry.as_deref() {
1187 Some(i)
1188 if i.eq_ignore_ascii_case("healthcare")
1189 && (field == "patient_id"
1190 || field == "mrn"
1191 || field == "medical_record_number") =>
1192 {
1193 tail.push_str(
1194 " Patient identifiers are opaque strings (UUID or hash); sequential integers would leak enrolment order.",
1195 );
1196 }
1197 Some(i)
1198 if i.eq_ignore_ascii_case("banking")
1199 && (field == "balance" || field == "amount" || field.ends_with("_amount")) =>
1200 {
1201 tail.push_str(
1202 " Monetary values are stored as integer minor units (öre, cents). Never use floats.",
1203 );
1204 }
1205 _ => {}
1206 }
1207 if ctx.requires_gdpr() && is_generic_pii_field(field) {
1209 tail.push_str(
1210 " Under GDPR this field is personal data — retention and right-to-erasure rules apply.",
1211 );
1212 }
1213 }
1214 format!("{head}{rationale}{tail}")
1215}
1216
1217fn is_generic_pii_field(name: &str) -> bool {
1218 matches!(
1219 name,
1220 "email" | "phone" | "address" | "date_of_birth" | "ssn" | "personnummer" | "fodselsnummer"
1221 )
1222}
1223
1224fn supported_forms_message(raw: &str) -> String {
1225 format!(
1226 "could not interpret prompt {raw:?}. Supported forms:\n \
1227 - add <field> to <model>\n \
1228 - add <field> as <type> to <model>\n \
1229 - add optional <field> to <model>\n \
1230 - rename <field> to <new> in <model>\n \
1231 - rename model <from> to <to>\n \
1232 - remove <field> from <model>\n \
1233 - change <field> in <model> to <type>\n \
1234 - make <field> in <model> optional|required"
1235 )
1236}
1237
1238pub fn render_plan_json(plan: &Plan, explanation: &str) -> String {
1252 let steps: Vec<serde_json::Value> = plan.steps.iter().map(primitive_to_cli_json).collect();
1253 let out = serde_json::json!({
1254 "plan": steps,
1255 "explanation": explanation,
1256 });
1257 serde_json::to_string_pretty(&out).unwrap_or_else(|_| "{}".to_string())
1258}
1259
1260pub fn render_plan_human(plan: &Plan, explanation: &str) -> String {
1263 let mut out = String::from("Plan:\n");
1264 if plan.steps.is_empty() {
1265 out.push_str(" (no changes)\n");
1266 }
1267 for step in &plan.steps {
1268 out.push_str(" - ");
1269 out.push_str(&summarise_primitive(step));
1270 out.push('\n');
1271 }
1272 out.push_str("\nExplanation:\n");
1273 out.push_str(explanation);
1274 if !explanation.ends_with('\n') {
1275 out.push('\n');
1276 }
1277 out
1278}
1279
1280fn primitive_to_cli_json(p: &Primitive) -> serde_json::Value {
1281 use serde_json::json;
1282 match p {
1283 Primitive::AddField(a) => json!({
1284 "op": "AddField",
1285 "model": a.model,
1286 "field": a.field.name,
1287 "type": a.field.ty,
1288 "nullable": a.field.nullable,
1289 }),
1290 Primitive::RemoveField(r) => json!({
1291 "op": "RemoveField",
1292 "model": r.model,
1293 "field": r.field,
1294 }),
1295 Primitive::RenameField(r) => json!({
1296 "op": "RenameField",
1297 "model": r.model,
1298 "from": r.from,
1299 "to": r.to,
1300 }),
1301 Primitive::RenameModel(r) => json!({
1302 "op": "RenameModel",
1303 "from": r.from,
1304 "to": r.to,
1305 }),
1306 Primitive::ChangeFieldType(c) => json!({
1307 "op": "ChangeFieldType",
1308 "model": c.model,
1309 "field": c.field,
1310 "type": c.new_type,
1311 }),
1312 Primitive::ChangeFieldNullability(c) => json!({
1313 "op": "ChangeFieldNullability",
1314 "model": c.model,
1315 "field": c.field,
1316 "nullable": c.nullable,
1317 }),
1318 Primitive::AddModel(m) => json!({
1319 "op": "AddModel",
1320 "name": m.name,
1321 "table": m.table,
1322 "fields": m.fields.iter().map(|f| json!({
1323 "name": f.name,
1324 "type": f.ty,
1325 "nullable": f.nullable,
1326 })).collect::<Vec<_>>(),
1327 }),
1328 Primitive::RemoveModel(m) => json!({
1329 "op": "RemoveModel",
1330 "name": m.name,
1331 }),
1332 Primitive::AddRelation(r) => json!({
1333 "op": "AddRelation",
1334 "from": r.from,
1335 "kind": format!("{:?}", r.kind).to_lowercase(),
1336 "to": r.to,
1337 "via": r.via,
1338 }),
1339 Primitive::RemoveRelation(r) => json!({
1340 "op": "RemoveRelation",
1341 "from": r.from,
1342 "via": r.via,
1343 }),
1344 Primitive::UpdateAdmin(u) => json!({
1345 "op": "UpdateAdmin",
1346 "model": u.model,
1347 "field": u.field,
1348 "attr": u.attr,
1349 "value": u.value,
1350 }),
1351 Primitive::CreateMigration(_) => {
1352 json!({"op": "CreateMigration", "note": "developer-only"})
1355 }
1356 }
1357}
1358
1359fn summarise_primitive(p: &Primitive) -> String {
1360 match p {
1361 Primitive::AddField(a) => format!(
1362 "Add field \"{}\" ({}{}) to model \"{}\"",
1363 a.field.name,
1364 a.field.ty,
1365 if a.field.nullable { ", nullable" } else { "" },
1366 a.model,
1367 ),
1368 Primitive::RemoveField(r) => {
1369 format!("Remove field \"{}\" from model \"{}\"", r.field, r.model)
1370 }
1371 Primitive::RenameField(r) => {
1372 format!("Rename field \"{}.{}\" to \"{}\"", r.model, r.from, r.to)
1373 }
1374 Primitive::RenameModel(r) => {
1375 format!("Rename model \"{}\" to \"{}\"", r.from, r.to)
1376 }
1377 Primitive::ChangeFieldType(c) => format!(
1378 "Change type of \"{}.{}\" to {}",
1379 c.model, c.field, c.new_type
1380 ),
1381 Primitive::ChangeFieldNullability(c) => format!(
1382 "Mark \"{}.{}\" as {}",
1383 c.model,
1384 c.field,
1385 if c.nullable { "nullable" } else { "required" },
1386 ),
1387 Primitive::AddModel(m) => format!(
1388 "Add model \"{}\" with {} field{}",
1389 m.name,
1390 m.fields.len(),
1391 if m.fields.len() == 1 { "" } else { "s" }
1392 ),
1393 Primitive::RemoveModel(m) => format!("Remove model \"{}\"", m.name),
1394 Primitive::AddRelation(r) => {
1395 format!("Add relation {:?}: {}.{} → {}", r.kind, r.from, r.via, r.to)
1396 }
1397 Primitive::RemoveRelation(r) => format!("Remove relation {}.{}", r.from, r.via),
1398 Primitive::UpdateAdmin(u) => {
1399 format!("Update admin attr \"{}.{}\".{}", u.model, u.field, u.attr)
1400 }
1401 Primitive::CreateMigration(m) => format!("[dev-only] create_migration \"{}\"", m.name),
1402 }
1403}