1use std::collections::HashSet;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::action::{ActionDef, GuardDef};
7use crate::field::{infer_meaning, DataType, FieldDef, FieldMeaning};
8use crate::intent::IntentHint;
9use crate::relationship::{Cardinality, RelationshipDef};
10use crate::state::{StateMachine, Warning};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ModelMetadata {
18 pub name: String,
19 pub display_name: Option<String>,
20 pub table: Option<String>,
21 pub fields: Vec<FieldMetadata>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct FieldMetadata {
27 pub name: String,
28 pub column_type: String,
30 pub is_primary_key: bool,
31 pub is_nullable: bool,
32}
33
34fn snake_to_title(s: &str) -> String {
36 s.split('_')
37 .map(|word| {
38 let mut c = word.chars();
39 match c.next() {
40 None => String::new(),
41 Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
42 }
43 })
44 .collect::<Vec<_>>()
45 .join(" ")
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
63pub struct ServiceDef {
64 pub name: String,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub display_name: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub description: Option<String>,
69 pub fields: Vec<FieldDef>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub actions: Vec<ActionDef>,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub guards: Vec<GuardDef>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub relationships: Vec<RelationshipDef>,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub intent_hints: Vec<IntentHint>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub state_machine: Option<StateMachine>,
80}
81
82impl ServiceDef {
83 pub fn new(name: impl Into<String>) -> Self {
85 Self {
86 name: name.into(),
87 display_name: None,
88 description: None,
89 fields: Vec::new(),
90 actions: Vec::new(),
91 guards: Vec::new(),
92 relationships: Vec::new(),
93 intent_hints: Vec::new(),
94 state_machine: None,
95 }
96 }
97
98 pub fn display_name(mut self, name: impl Into<String>) -> Self {
100 self.display_name = Some(name.into());
101 self
102 }
103
104 pub fn description(mut self, desc: impl Into<String>) -> Self {
106 self.description = Some(desc.into());
107 self
108 }
109
110 pub fn field(
112 mut self,
113 name: impl Into<String>,
114 data_type: DataType,
115 meaning: FieldMeaning,
116 ) -> Self {
117 self.fields.push(FieldDef {
118 name: name.into(),
119 data_type,
120 meaning,
121 required: true,
122 is_list: false,
123 readable: true,
124 writable: true,
125 });
126 self
127 }
128
129 pub fn optional_field(
131 mut self,
132 name: impl Into<String>,
133 data_type: DataType,
134 meaning: FieldMeaning,
135 ) -> Self {
136 self.fields.push(FieldDef {
137 name: name.into(),
138 data_type,
139 meaning,
140 required: false,
141 is_list: false,
142 readable: true,
143 writable: true,
144 });
145 self
146 }
147
148 pub fn list_field(
150 mut self,
151 name: impl Into<String>,
152 data_type: DataType,
153 meaning: FieldMeaning,
154 ) -> Self {
155 self.fields.push(FieldDef {
156 name: name.into(),
157 data_type,
158 meaning,
159 required: true,
160 is_list: true,
161 readable: true,
162 writable: true,
163 });
164 self
165 }
166
167 pub fn read_only_field(
171 mut self,
172 name: impl Into<String>,
173 data_type: DataType,
174 meaning: FieldMeaning,
175 ) -> Self {
176 self.fields.push(FieldDef {
177 name: name.into(),
178 data_type,
179 meaning,
180 required: true,
181 is_list: false,
182 readable: true,
183 writable: false,
184 });
185 self
186 }
187
188 pub fn write_only_field(
192 mut self,
193 name: impl Into<String>,
194 data_type: DataType,
195 meaning: FieldMeaning,
196 ) -> Self {
197 self.fields.push(FieldDef {
198 name: name.into(),
199 data_type,
200 meaning,
201 required: true,
202 is_list: false,
203 readable: false,
204 writable: true,
205 });
206 self
207 }
208
209 pub fn action(mut self, action: ActionDef) -> Self {
211 self.actions.push(action);
212 self
213 }
214
215 pub fn guard(mut self, guard: GuardDef) -> Self {
217 self.guards.push(guard);
218 self
219 }
220
221 pub fn relationship(mut self, rel: RelationshipDef) -> Self {
223 self.relationships.push(rel);
224 self
225 }
226
227 pub fn belongs_to(self, name: impl Into<String>, target: impl Into<String>) -> Self {
229 self.relationship(RelationshipDef::new(name, target, Cardinality::ManyToOne))
230 }
231
232 pub fn has_many(self, name: impl Into<String>, target: impl Into<String>) -> Self {
234 self.relationship(RelationshipDef::new(name, target, Cardinality::OneToMany))
235 }
236
237 pub fn has_one(self, name: impl Into<String>, target: impl Into<String>) -> Self {
239 self.relationship(RelationshipDef::new(name, target, Cardinality::OneToOne))
240 }
241
242 pub fn belongs_to_many(self, name: impl Into<String>, target: impl Into<String>) -> Self {
244 self.relationship(RelationshipDef::new(name, target, Cardinality::ManyToMany))
245 }
246
247 pub fn intent_hint(mut self, hint: IntentHint) -> Self {
249 self.intent_hints.push(hint);
250 self
251 }
252
253 pub fn state_machine(mut self, machine: StateMachine) -> Self {
255 self.state_machine = Some(machine);
256 self
257 }
258
259 pub fn from_model(meta: &ModelMetadata) -> Self {
265 let display = meta
266 .display_name
267 .clone()
268 .unwrap_or_else(|| snake_to_title(&meta.name));
269
270 let mut def = Self::new(&meta.name).display_name(display);
271
272 for field in &meta.fields {
273 let data_type = DataType::from_column_type(&field.column_type);
274 let meaning = infer_meaning(&field.name);
275
276 let is_system = matches!(field.name.as_str(), "id" | "created_at" | "updated_at")
277 || field.is_primary_key;
278
279 def.fields.push(FieldDef {
280 name: field.name.clone(),
281 data_type,
282 meaning,
283 required: !field.is_nullable,
284 is_list: false,
285 readable: true,
286 writable: !is_system,
287 });
288 }
289
290 def
291 }
292
293 pub fn validate(&self) -> Result<Vec<Warning>, crate::Error> {
301 let mut warnings = Vec::new();
302
303 if let Some(ref sm) = self.state_machine {
305 warnings.extend(sm.validate()?);
306 }
307
308 let declared_guards: HashSet<&str> = self.guards.iter().map(|g| g.name.as_str()).collect();
310
311 for action in &self.actions {
313 for precondition in &action.preconditions {
314 if !declared_guards.contains(precondition.as_str()) {
315 return Err(crate::Error::Validation(format!(
316 "action '{}' references undefined guard '{}'",
317 action.name, precondition
318 )));
319 }
320 }
321 }
322
323 if let Some(ref sm) = self.state_machine {
325 for transition in &sm.transitions {
326 if let Some(ref guard) = transition.guard {
327 if !declared_guards.contains(guard.as_str()) {
328 return Err(crate::Error::Validation(format!(
329 "transition '{}' -> '{}' references undefined guard '{}'",
330 transition.from, transition.to, guard
331 )));
332 }
333 }
334 }
335 }
336
337 if let Some(ref sm) = self.state_machine {
339 let event_names: HashSet<&str> =
340 sm.transitions.iter().map(|t| t.event.as_str()).collect();
341 for action in &self.actions {
342 if let Some(ref trigger) = action.transition_trigger {
343 if !event_names.contains(trigger.as_str()) {
344 return Err(crate::Error::Validation(format!(
345 "action '{}' has transition_trigger '{}' that does not match any state machine event",
346 action.name, trigger
347 )));
348 }
349 }
350 }
351 }
352
353 let mut referenced_guards: HashSet<&str> = HashSet::new();
355 for action in &self.actions {
356 for precondition in &action.preconditions {
357 referenced_guards.insert(precondition.as_str());
358 }
359 }
360 if let Some(ref sm) = self.state_machine {
361 for transition in &sm.transitions {
362 if let Some(ref guard) = transition.guard {
363 referenced_guards.insert(guard.as_str());
364 }
365 }
366 }
367 for guard in &self.guards {
368 if !referenced_guards.contains(guard.name.as_str()) {
369 warnings.push(Warning::UnusedGuard(guard.name.clone()));
370 }
371 }
372
373 if self.state_machine.is_none() {
375 for action in &self.actions {
376 if action.transition_trigger.is_some() {
377 warnings.push(Warning::TransitionTriggerWithoutStateMachine(
378 action.name.clone(),
379 ));
380 }
381 }
382 }
383
384 {
386 let mut seen = HashSet::new();
387 for rel in &self.relationships {
388 if !seen.insert(rel.name.as_str()) {
389 warnings.push(Warning::DuplicateRelationship(rel.name.clone()));
390 }
391 }
392 }
393
394 for rel in &self.relationships {
396 if rel.cardinality == Cardinality::ManyToMany && rel.foreign_key.is_some() {
397 warnings.push(Warning::ManyToManyWithForeignKey {
398 relationship: rel.name.clone(),
399 });
400 }
401 }
402
403 {
405 let mut primaries = HashSet::new();
406 let mut excludes = HashSet::new();
407 let mut primary_count = 0u32;
408
409 for hint in &self.intent_hints {
410 match hint {
411 IntentHint::Primary(intent) => {
412 primary_count += 1;
413 let serialized = serde_json::to_string(intent)
414 .unwrap_or_default()
415 .trim_matches('"')
416 .to_string();
417 primaries.insert(serialized);
418 }
419 IntentHint::Exclude(intent) => {
420 let serialized = serde_json::to_string(intent)
421 .unwrap_or_default()
422 .trim_matches('"')
423 .to_string();
424 excludes.insert(serialized);
425 }
426 }
427 }
428
429 for intent_name in primaries.intersection(&excludes) {
430 warnings.push(Warning::ConflictingIntentHints {
431 intent: intent_name.clone(),
432 });
433 }
434
435 if primary_count > 1 {
436 warnings.push(Warning::MultiplePrimaryIntentHints);
437 }
438 }
439
440 Ok(warnings)
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn service_def_builder_chain() {
450 let service = ServiceDef::new("order")
451 .display_name("Order")
452 .description("Manages customer orders")
453 .field("id", DataType::Integer, FieldMeaning::Identifier)
454 .field("total", DataType::Float, FieldMeaning::Money)
455 .field("status", DataType::String, FieldMeaning::Status)
456 .optional_field("notes", DataType::String, FieldMeaning::FreeText)
457 .list_field("tags", DataType::String, FieldMeaning::Category);
458
459 assert_eq!(service.name, "order");
460 assert_eq!(service.display_name.as_deref(), Some("Order"));
461 assert_eq!(
462 service.description.as_deref(),
463 Some("Manages customer orders")
464 );
465 assert_eq!(service.fields.len(), 5);
466
467 assert!(service.fields[0].required);
469 assert!(!service.fields[0].is_list);
470
471 assert!(!service.fields[3].required);
473 assert!(!service.fields[3].is_list);
474
475 assert!(service.fields[4].required);
477 assert!(service.fields[4].is_list);
478 }
479
480 #[test]
481 fn service_def_minimal() {
482 let service = ServiceDef::new("user");
483 assert_eq!(service.name, "user");
484 assert!(service.display_name.is_none());
485 assert!(service.description.is_none());
486 assert!(service.fields.is_empty());
487 }
488
489 #[test]
490 fn service_def_serde_round_trip() {
491 let service = ServiceDef::new("order")
492 .display_name("Order")
493 .field("id", DataType::Integer, FieldMeaning::Identifier)
494 .field("total", DataType::Float, FieldMeaning::Money)
495 .optional_field("notes", DataType::String, FieldMeaning::FreeText);
496
497 let json = serde_json::to_string(&service).unwrap();
498 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
499 assert_eq!(service, parsed);
500 }
501
502 #[test]
503 fn service_def_json_omits_none_fields() {
504 let service = ServiceDef::new("order");
505 let json = serde_json::to_string(&service).unwrap();
506 assert!(!json.contains("display_name"));
507 assert!(!json.contains("description"));
508 }
509
510 #[test]
511 fn service_def_multiple_fields() {
512 let service = ServiceDef::new("product")
513 .field("id", DataType::Integer, FieldMeaning::Identifier)
514 .field("name", DataType::String, FieldMeaning::EntityName)
515 .field("price", DataType::Float, FieldMeaning::Money)
516 .field("sku", DataType::String, FieldMeaning::Custom("sku".into()))
517 .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
518
519 assert_eq!(service.fields.len(), 5);
520 assert_eq!(service.fields[0].name, "id");
522 assert_eq!(service.fields[1].name, "name");
523 assert_eq!(service.fields[2].name, "price");
524 assert_eq!(service.fields[3].name, "sku");
525 assert_eq!(service.fields[4].name, "created_at");
526 }
527
528 #[test]
529 fn service_def_json_structure() {
530 let service = ServiceDef::new("order")
531 .display_name("Order")
532 .description("Customer orders")
533 .field("id", DataType::Integer, FieldMeaning::Identifier)
534 .optional_field("notes", DataType::String, FieldMeaning::FreeText);
535
536 let json = serde_json::to_string(&service).unwrap();
537 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
538
539 assert!(value.get("name").is_some());
540 assert!(value.get("display_name").is_some());
541 assert!(value.get("description").is_some());
542 assert!(value.get("fields").is_some());
543
544 let fields = value["fields"].as_array().unwrap();
545 assert_eq!(fields.len(), 2);
546 }
547
548 #[test]
549 fn order_service_example() {
550 let service = ServiceDef::new("order")
551 .display_name("Order")
552 .description("Manages customer orders and fulfillment")
553 .field("id", DataType::Integer, FieldMeaning::Identifier)
554 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
555 .field("total", DataType::Float, FieldMeaning::Money)
556 .field("status", DataType::String, FieldMeaning::Status)
557 .field("email", DataType::String, FieldMeaning::Email)
558 .field("notes", DataType::String, FieldMeaning::FreeText)
559 .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
560 .field("updated_at", DataType::DateTime, FieldMeaning::UpdatedAt);
561
562 assert_eq!(service.fields.len(), 8);
563 assert_eq!(service.fields[2].meaning, FieldMeaning::Money);
564 assert_eq!(service.fields[3].meaning, FieldMeaning::Status);
565
566 let json = serde_json::to_string(&service).unwrap();
568 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
569 assert_eq!(service, parsed);
570 }
571
572 use crate::state::{StateDef, StateMachine, Transition};
575
576 #[test]
577 fn service_def_with_state_machine() {
578 let machine = StateMachine::new("order_lifecycle")
579 .initial("draft")
580 .state(StateDef::new("draft"))
581 .state(StateDef::new("completed").final_state())
582 .transition(Transition::new("draft", "complete", "completed"));
583
584 let service = ServiceDef::new("order")
585 .field("id", DataType::Integer, FieldMeaning::Identifier)
586 .state_machine(machine);
587
588 assert!(service.state_machine.is_some());
589 let sm = service.state_machine.as_ref().unwrap();
590 assert_eq!(sm.states.len(), 2);
591 assert_eq!(sm.transitions.len(), 1);
592 }
593
594 #[test]
595 fn service_def_state_machine_serde_round_trip() {
596 let machine = StateMachine::new("order_lifecycle")
597 .initial("draft")
598 .state(StateDef::new("draft").display_name("Draft"))
599 .state(
600 StateDef::new("completed")
601 .display_name("Completed")
602 .final_state(),
603 )
604 .transition(
605 Transition::new("draft", "complete", "completed")
606 .guard("is_valid")
607 .actions(vec!["notify"]),
608 );
609
610 let service = ServiceDef::new("order")
611 .display_name("Order")
612 .field("id", DataType::Integer, FieldMeaning::Identifier)
613 .field("status", DataType::String, FieldMeaning::Status)
614 .state_machine(machine);
615
616 let json = serde_json::to_string_pretty(&service).unwrap();
617 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
618 assert_eq!(service, parsed);
619 }
620
621 #[test]
622 fn service_def_without_state_machine_json() {
623 let service =
624 ServiceDef::new("user").field("id", DataType::Integer, FieldMeaning::Identifier);
625
626 let json = serde_json::to_string(&service).unwrap();
627 assert!(!json.contains("state_machine"));
628 }
629
630 #[test]
631 fn order_service_full_example() {
632 let machine = StateMachine::new("order_lifecycle")
633 .display_name("Order Lifecycle")
634 .description("Tracks an order from creation to fulfillment")
635 .initial("draft")
636 .state(
637 StateDef::new("draft")
638 .display_name("Draft")
639 .description("Order is being prepared"),
640 )
641 .state(
642 StateDef::new("submitted")
643 .display_name("Submitted")
644 .on_enter(vec!["validate_inventory", "calculate_totals"]),
645 )
646 .state(
647 StateDef::new("processing")
648 .display_name("Processing")
649 .on_enter(vec!["charge_payment", "reserve_inventory"]),
650 )
651 .state(
652 StateDef::new("shipped")
653 .display_name("Shipped")
654 .on_enter(vec!["generate_tracking", "notify_customer"]),
655 )
656 .state(
657 StateDef::new("delivered")
658 .display_name("Delivered")
659 .final_state(),
660 )
661 .state(
662 StateDef::new("cancelled")
663 .display_name("Cancelled")
664 .final_state()
665 .on_enter(vec!["refund_payment", "release_inventory"]),
666 )
667 .transition(
668 Transition::new("draft", "submit", "submitted")
669 .guard("has_items")
670 .description("Customer submits the order"),
671 )
672 .transition(
673 Transition::new("submitted", "process", "processing")
674 .guard("payment_valid")
675 .actions(vec!["lock_prices"]),
676 )
677 .transition(
678 Transition::new("processing", "ship", "shipped").guard("inventory_fulfilled"),
679 )
680 .transition(Transition::new("shipped", "deliver", "delivered"))
681 .transition(Transition::new("draft", "cancel", "cancelled"))
682 .transition(
683 Transition::new("submitted", "cancel", "cancelled").guard("cancellation_allowed"),
684 )
685 .transition(
686 Transition::new("processing", "cancel", "cancelled")
687 .guard("cancellation_allowed")
688 .actions(vec!["reverse_payment"]),
689 );
690
691 let service = ServiceDef::new("order")
692 .display_name("Order")
693 .description("Manages customer orders and fulfillment")
694 .field("id", DataType::Integer, FieldMeaning::Identifier)
695 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
696 .field("total", DataType::Float, FieldMeaning::Money)
697 .field("status", DataType::String, FieldMeaning::Status)
698 .field("email", DataType::String, FieldMeaning::Email)
699 .field("notes", DataType::String, FieldMeaning::FreeText)
700 .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
701 .field("updated_at", DataType::DateTime, FieldMeaning::UpdatedAt)
702 .state_machine(machine);
703
704 assert_eq!(service.fields.len(), 8);
706
707 let sm = service.state_machine.as_ref().unwrap();
709 assert_eq!(sm.states.len(), 6);
710 assert_eq!(sm.transitions.len(), 7);
711 assert_eq!(sm.initial_state, "draft");
712
713 let warnings = sm.validate().unwrap();
715 assert!(warnings.is_empty());
716
717 let json = serde_json::to_string_pretty(&service).unwrap();
719 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
720 assert_eq!(service, parsed);
721 }
722
723 #[test]
724 fn service_def_json_schema() {
725 let schema = schemars::schema_for!(ServiceDef);
726 let value = schema.to_value();
727 let props = value
728 .get("properties")
729 .expect("ServiceDef schema must have properties");
730 let obj = props.as_object().unwrap();
731 assert!(obj.contains_key("name"), "missing 'name' property");
732 assert!(obj.contains_key("fields"), "missing 'fields' property");
733 assert!(
734 obj.contains_key("state_machine"),
735 "missing 'state_machine' property"
736 );
737 }
738
739 #[test]
742 fn read_only_field_builder() {
743 let service = ServiceDef::new("order")
744 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
745 .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
746
747 assert_eq!(service.fields.len(), 2);
748 for f in &service.fields {
749 assert!(f.readable);
750 assert!(!f.writable);
751 assert!(f.required);
752 assert!(!f.is_list);
753 }
754 }
755
756 #[test]
757 fn write_only_field_builder() {
758 let service = ServiceDef::new("user").write_only_field(
759 "password",
760 DataType::String,
761 FieldMeaning::Sensitive,
762 );
763
764 assert_eq!(service.fields.len(), 1);
765 let f = &service.fields[0];
766 assert!(!f.readable);
767 assert!(f.writable);
768 assert!(f.required);
769 assert!(!f.is_list);
770 }
771
772 #[test]
773 fn mixed_access_fields_serde_round_trip() {
774 let service = ServiceDef::new("user")
775 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
776 .field("name", DataType::String, FieldMeaning::EntityName)
777 .write_only_field("password", DataType::String, FieldMeaning::Sensitive)
778 .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
779
780 let json = serde_json::to_string(&service).unwrap();
781 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
782 assert_eq!(service, parsed);
783
784 assert!(parsed.fields[0].readable);
786 assert!(!parsed.fields[0].writable);
787 assert!(parsed.fields[1].readable);
788 assert!(parsed.fields[1].writable);
789 assert!(!parsed.fields[2].readable);
790 assert!(parsed.fields[2].writable);
791 assert!(parsed.fields[3].readable);
792 assert!(!parsed.fields[3].writable);
793 }
794
795 #[test]
796 fn existing_field_builders_default_read_write() {
797 let service = ServiceDef::new("order")
798 .field("id", DataType::Integer, FieldMeaning::Identifier)
799 .optional_field("notes", DataType::String, FieldMeaning::FreeText)
800 .list_field("tags", DataType::String, FieldMeaning::Category);
801
802 for f in &service.fields {
803 assert!(f.readable, "field '{}' should be readable", f.name);
804 assert!(f.writable, "field '{}' should be writable", f.name);
805 }
806 }
807
808 use crate::action::{ActionDef, GuardDef, InputDef};
811 use crate::state::Warning;
812
813 #[test]
814 fn service_def_with_actions_and_guards_builder() {
815 let service = ServiceDef::new("order")
816 .guard(GuardDef::new("has_items"))
817 .guard(GuardDef::new("payment_valid"))
818 .action(
819 ActionDef::new("submit_order")
820 .precondition("has_items")
821 .precondition("payment_valid"),
822 )
823 .action(ActionDef::new("update_notes"));
824
825 assert_eq!(service.guards.len(), 2);
826 assert_eq!(service.actions.len(), 2);
827 assert_eq!(service.actions[0].name, "submit_order");
828 assert_eq!(service.actions[1].name, "update_notes");
829 }
830
831 #[test]
832 fn service_def_serde_round_trip_with_actions_guards() {
833 let service = ServiceDef::new("order")
834 .field("id", DataType::Integer, FieldMeaning::Identifier)
835 .guard(GuardDef::new("has_items").display_name("Has Items"))
836 .action(
837 ActionDef::new("submit")
838 .input(InputDef::new(
839 "order_id",
840 DataType::Integer,
841 FieldMeaning::Identifier,
842 ))
843 .precondition("has_items")
844 .effect("notify"),
845 );
846
847 let json = serde_json::to_string_pretty(&service).unwrap();
848 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
849 assert_eq!(service, parsed);
850 }
851
852 #[test]
853 fn service_def_json_omits_empty_actions_guards() {
854 let service = ServiceDef::new("user");
855 let json = serde_json::to_string(&service).unwrap();
856 assert!(!json.contains("actions"));
857 assert!(!json.contains("guards"));
858 }
859
860 #[test]
861 fn validate_passes_valid_service() {
862 let machine = StateMachine::new("order_lifecycle")
863 .initial("draft")
864 .state(StateDef::new("draft"))
865 .state(StateDef::new("submitted").final_state())
866 .transition(Transition::new("draft", "submit", "submitted").guard("has_items"));
867
868 let service = ServiceDef::new("order")
869 .field("id", DataType::Integer, FieldMeaning::Identifier)
870 .guard(GuardDef::new("has_items"))
871 .action(
872 ActionDef::new("submit_order")
873 .precondition("has_items")
874 .transition_trigger("submit"),
875 )
876 .state_machine(machine);
877
878 let warnings = service.validate().unwrap();
879 assert!(warnings.is_empty());
880 }
881
882 #[test]
883 fn validate_catches_undefined_action_precondition() {
884 let service = ServiceDef::new("order")
885 .guard(GuardDef::new("has_items"))
886 .action(ActionDef::new("submit").precondition("nonexistent_guard"));
887
888 let result = service.validate();
889 assert!(result.is_err());
890 let err = result.unwrap_err().to_string();
891 assert!(err.contains("nonexistent_guard"));
892 assert!(err.contains("submit"));
893 }
894
895 #[test]
896 fn validate_catches_undefined_transition_guard() {
897 let machine = StateMachine::new("lifecycle")
898 .initial("draft")
899 .state(StateDef::new("draft"))
900 .state(StateDef::new("done").final_state())
901 .transition(Transition::new("draft", "finish", "done").guard("undefined_guard"));
902
903 let service = ServiceDef::new("order").state_machine(machine);
904
905 let result = service.validate();
906 assert!(result.is_err());
907 let err = result.unwrap_err().to_string();
908 assert!(err.contains("undefined_guard"));
909 }
910
911 #[test]
912 fn validate_catches_unmatched_transition_trigger() {
913 let machine = StateMachine::new("lifecycle")
914 .initial("draft")
915 .state(StateDef::new("draft"))
916 .state(StateDef::new("done").final_state())
917 .transition(Transition::new("draft", "finish", "done"));
918
919 let service = ServiceDef::new("order")
920 .action(ActionDef::new("submit").transition_trigger("nonexistent_event"))
921 .state_machine(machine);
922
923 let result = service.validate();
924 assert!(result.is_err());
925 let err = result.unwrap_err().to_string();
926 assert!(err.contains("nonexistent_event"));
927 }
928
929 #[test]
930 fn validate_warns_unused_guards() {
931 let service = ServiceDef::new("order")
932 .guard(GuardDef::new("used_guard"))
933 .guard(GuardDef::new("unused_guard"))
934 .action(ActionDef::new("submit").precondition("used_guard"));
935
936 let warnings = service.validate().unwrap();
937 assert_eq!(warnings.len(), 1);
938 assert!(warnings.contains(&Warning::UnusedGuard("unused_guard".into())));
939 }
940
941 #[test]
942 fn validate_warns_transition_trigger_without_state_machine() {
943 let service =
944 ServiceDef::new("order").action(ActionDef::new("submit").transition_trigger("submit"));
945
946 let warnings = service.validate().unwrap();
947 assert_eq!(warnings.len(), 1);
948 assert!(
949 warnings.contains(&Warning::TransitionTriggerWithoutStateMachine(
950 "submit".into()
951 ))
952 );
953 }
954
955 #[test]
956 fn validate_delegates_to_state_machine_validate() {
957 let machine = StateMachine::new("lifecycle")
959 .initial("nonexistent")
960 .state(StateDef::new("a").final_state());
961
962 let service = ServiceDef::new("order").state_machine(machine);
963
964 let result = service.validate();
965 assert!(result.is_err());
966 let err = result.unwrap_err().to_string();
967 assert!(err.contains("nonexistent"));
968 }
969
970 #[test]
971 fn validate_without_state_machine_or_actions_passes_clean() {
972 let service =
973 ServiceDef::new("simple").field("id", DataType::Integer, FieldMeaning::Identifier);
974
975 let warnings = service.validate().unwrap();
976 assert!(warnings.is_empty());
977 }
978
979 #[test]
980 fn full_order_service_with_guards_actions_validates_clean() {
981 let machine = StateMachine::new("order_lifecycle")
982 .display_name("Order Lifecycle")
983 .initial("draft")
984 .state(StateDef::new("draft").display_name("Draft"))
985 .state(StateDef::new("submitted").display_name("Submitted"))
986 .state(StateDef::new("processing").display_name("Processing"))
987 .state(
988 StateDef::new("shipped")
989 .display_name("Shipped")
990 .final_state(),
991 )
992 .state(
993 StateDef::new("cancelled")
994 .display_name("Cancelled")
995 .final_state(),
996 )
997 .transition(Transition::new("draft", "submit", "submitted").guard("has_items"))
998 .transition(
999 Transition::new("submitted", "process", "processing").guard("payment_valid"),
1000 )
1001 .transition(
1002 Transition::new("processing", "ship", "shipped").guard("inventory_fulfilled"),
1003 )
1004 .transition(
1005 Transition::new("draft", "cancel", "cancelled").guard("cancellation_allowed"),
1006 )
1007 .transition(
1008 Transition::new("submitted", "cancel", "cancelled").guard("cancellation_allowed"),
1009 );
1010
1011 let service = ServiceDef::new("order")
1012 .display_name("Order")
1013 .description("Full order management")
1014 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1015 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
1016 .field("total", DataType::Float, FieldMeaning::Money)
1017 .field("status", DataType::String, FieldMeaning::Status)
1018 .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
1019 .guard(GuardDef::new("has_items").display_name("Has Items"))
1020 .guard(GuardDef::new("payment_valid").display_name("Payment Valid"))
1021 .guard(GuardDef::new("inventory_fulfilled").display_name("Inventory Fulfilled"))
1022 .guard(GuardDef::new("cancellation_allowed").display_name("Cancellation Allowed"))
1023 .action(
1024 ActionDef::new("submit_order")
1025 .display_name("Submit Order")
1026 .input(InputDef::new(
1027 "order_id",
1028 DataType::Integer,
1029 FieldMeaning::Identifier,
1030 ))
1031 .precondition("has_items")
1032 .effect("notify_customer")
1033 .transition_trigger("submit"),
1034 )
1035 .action(
1036 ActionDef::new("process_order")
1037 .precondition("payment_valid")
1038 .transition_trigger("process"),
1039 )
1040 .action(
1041 ActionDef::new("ship_order")
1042 .precondition("inventory_fulfilled")
1043 .transition_trigger("ship"),
1044 )
1045 .action(
1046 ActionDef::new("cancel_order")
1047 .precondition("cancellation_allowed")
1048 .effect("refund_payment")
1049 .transition_trigger("cancel"),
1050 )
1051 .state_machine(machine);
1052
1053 let warnings = service.validate().unwrap();
1055 assert!(
1056 warnings.is_empty(),
1057 "expected no warnings, got: {warnings:?}"
1058 );
1059
1060 assert_eq!(service.fields.len(), 5);
1062 assert_eq!(service.guards.len(), 4);
1063 assert_eq!(service.actions.len(), 4);
1064 assert!(service.state_machine.is_some());
1065
1066 let json = serde_json::to_string_pretty(&service).unwrap();
1068 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1069 assert_eq!(service, parsed);
1070 }
1071
1072 #[test]
1073 fn service_def_json_schema_includes_actions_guards() {
1074 let schema = schemars::schema_for!(ServiceDef);
1075 let value = schema.to_value();
1076 let props = value
1077 .get("properties")
1078 .expect("ServiceDef schema must have properties");
1079 let obj = props.as_object().unwrap();
1080 assert!(obj.contains_key("actions"), "missing 'actions' property");
1081 assert!(obj.contains_key("guards"), "missing 'guards' property");
1082 }
1083
1084 use crate::relationship::{Cardinality, NavigationHint, RelationshipDef};
1087
1088 #[test]
1089 fn service_def_with_relationships_builder() {
1090 let service = ServiceDef::new("order").relationship(
1091 RelationshipDef::new("customer", "customer", Cardinality::ManyToOne)
1092 .foreign_key("customer_id"),
1093 );
1094
1095 assert_eq!(service.relationships.len(), 1);
1096 assert_eq!(service.relationships[0].name, "customer");
1097 assert_eq!(service.relationships[0].target, "customer");
1098 assert_eq!(service.relationships[0].cardinality, Cardinality::ManyToOne);
1099 }
1100
1101 #[test]
1102 fn service_def_belongs_to_convenience() {
1103 let service = ServiceDef::new("order").belongs_to("customer", "customer");
1104
1105 assert_eq!(service.relationships.len(), 1);
1106 let rel = &service.relationships[0];
1107 assert_eq!(rel.name, "customer");
1108 assert_eq!(rel.target, "customer");
1109 assert_eq!(rel.cardinality, Cardinality::ManyToOne);
1110 assert_eq!(rel.navigation, NavigationHint::Link);
1111 }
1112
1113 #[test]
1114 fn service_def_has_many_convenience() {
1115 let service = ServiceDef::new("order").has_many("line_items", "order_line_item");
1116
1117 assert_eq!(service.relationships.len(), 1);
1118 let rel = &service.relationships[0];
1119 assert_eq!(rel.name, "line_items");
1120 assert_eq!(rel.target, "order_line_item");
1121 assert_eq!(rel.cardinality, Cardinality::OneToMany);
1122 assert_eq!(rel.navigation, NavigationHint::Nested);
1123 }
1124
1125 #[test]
1126 fn service_def_has_one_convenience() {
1127 let service = ServiceDef::new("user").has_one("profile", "user_profile");
1128
1129 assert_eq!(service.relationships.len(), 1);
1130 let rel = &service.relationships[0];
1131 assert_eq!(rel.name, "profile");
1132 assert_eq!(rel.target, "user_profile");
1133 assert_eq!(rel.cardinality, Cardinality::OneToOne);
1134 assert_eq!(rel.navigation, NavigationHint::Inline);
1135 }
1136
1137 #[test]
1138 fn service_def_belongs_to_many_convenience() {
1139 let service = ServiceDef::new("post").belongs_to_many("tags", "tag");
1140
1141 assert_eq!(service.relationships.len(), 1);
1142 let rel = &service.relationships[0];
1143 assert_eq!(rel.name, "tags");
1144 assert_eq!(rel.target, "tag");
1145 assert_eq!(rel.cardinality, Cardinality::ManyToMany);
1146 assert_eq!(rel.navigation, NavigationHint::Nested);
1147 }
1148
1149 #[test]
1150 fn service_def_json_omits_empty_relationships() {
1151 let service = ServiceDef::new("user");
1152 let json = serde_json::to_string(&service).unwrap();
1153 assert!(!json.contains("relationships"));
1154 }
1155
1156 #[test]
1157 fn service_def_relationships_serde_round_trip() {
1158 let service = ServiceDef::new("order")
1159 .field("id", DataType::Integer, FieldMeaning::Identifier)
1160 .belongs_to("customer", "customer")
1161 .has_many("line_items", "order_line_item")
1162 .has_one("invoice", "invoice")
1163 .belongs_to_many("tags", "tag");
1164
1165 let json = serde_json::to_string_pretty(&service).unwrap();
1166 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1167 assert_eq!(service, parsed);
1168 assert_eq!(parsed.relationships.len(), 4);
1169 }
1170
1171 #[test]
1174 fn validate_warns_duplicate_relationship_names() {
1175 let service = ServiceDef::new("order")
1176 .belongs_to("customer", "customer")
1177 .belongs_to("customer", "other_customer");
1178
1179 let warnings = service.validate().unwrap();
1180 assert!(warnings.contains(&Warning::DuplicateRelationship("customer".into())));
1181 }
1182
1183 #[test]
1184 fn validate_warns_many_to_many_with_foreign_key() {
1185 let service = ServiceDef::new("post").relationship(
1186 RelationshipDef::new("tags", "tag", Cardinality::ManyToMany).foreign_key("tag_id"),
1187 );
1188
1189 let warnings = service.validate().unwrap();
1190 assert!(warnings.contains(&Warning::ManyToManyWithForeignKey {
1191 relationship: "tags".into()
1192 }));
1193 }
1194
1195 #[test]
1196 fn validate_passes_with_valid_relationships() {
1197 let service = ServiceDef::new("order")
1198 .field("id", DataType::Integer, FieldMeaning::Identifier)
1199 .belongs_to("customer", "customer")
1200 .has_many("line_items", "order_line_item");
1201
1202 let warnings = service.validate().unwrap();
1203 assert!(
1204 warnings.is_empty(),
1205 "expected no warnings, got: {warnings:?}"
1206 );
1207 }
1208
1209 #[test]
1210 fn order_service_with_relationships_full_example() {
1211 let machine = StateMachine::new("order_lifecycle")
1212 .initial("draft")
1213 .state(StateDef::new("draft").display_name("Draft"))
1214 .state(
1215 StateDef::new("submitted")
1216 .display_name("Submitted")
1217 .final_state(),
1218 )
1219 .transition(Transition::new("draft", "submit", "submitted").guard("has_items"));
1220
1221 let service = ServiceDef::new("order")
1222 .display_name("Order")
1223 .description("Full order management with relationships")
1224 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1225 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
1226 .field("total", DataType::Float, FieldMeaning::Money)
1227 .field("status", DataType::String, FieldMeaning::Status)
1228 .guard(GuardDef::new("has_items"))
1229 .action(
1230 ActionDef::new("submit_order")
1231 .precondition("has_items")
1232 .transition_trigger("submit"),
1233 )
1234 .belongs_to("customer", "customer")
1235 .has_many("line_items", "order_line_item")
1236 .has_one("invoice", "invoice")
1237 .state_machine(machine);
1238
1239 let warnings = service.validate().unwrap();
1241 assert!(
1242 warnings.is_empty(),
1243 "expected no warnings, got: {warnings:?}"
1244 );
1245
1246 assert_eq!(service.fields.len(), 4);
1248 assert_eq!(service.guards.len(), 1);
1249 assert_eq!(service.actions.len(), 1);
1250 assert_eq!(service.relationships.len(), 3);
1251 assert!(service.state_machine.is_some());
1252
1253 let json = serde_json::to_string_pretty(&service).unwrap();
1255 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1256 assert_eq!(service, parsed);
1257 }
1258
1259 #[test]
1260 fn service_def_json_schema_includes_relationships() {
1261 let schema = schemars::schema_for!(ServiceDef);
1262 let value = schema.to_value();
1263 let props = value
1264 .get("properties")
1265 .expect("ServiceDef schema must have properties");
1266 let obj = props.as_object().unwrap();
1267 assert!(
1268 obj.contains_key("relationships"),
1269 "missing 'relationships' property"
1270 );
1271 }
1272
1273 use crate::intent::{Intent, IntentHint};
1276
1277 #[test]
1278 fn service_def_new_has_empty_intent_hints() {
1279 let service = ServiceDef::new("order");
1280 assert!(service.intent_hints.is_empty());
1281 }
1282
1283 #[test]
1284 fn service_def_intent_hint_builder() {
1285 let service = ServiceDef::new("order")
1286 .intent_hint(IntentHint::Primary(Intent::Browse))
1287 .intent_hint(IntentHint::Exclude(Intent::Process));
1288
1289 assert_eq!(service.intent_hints.len(), 2);
1290 assert_eq!(service.intent_hints[0], IntentHint::Primary(Intent::Browse));
1291 assert_eq!(
1292 service.intent_hints[1],
1293 IntentHint::Exclude(Intent::Process)
1294 );
1295 }
1296
1297 #[test]
1298 fn service_def_json_omits_empty_intent_hints() {
1299 let service = ServiceDef::new("user");
1300 let json = serde_json::to_string(&service).unwrap();
1301 assert!(!json.contains("intent_hints"));
1302 }
1303
1304 #[test]
1305 fn service_def_intent_hints_serde_round_trip() {
1306 let service = ServiceDef::new("order")
1307 .field("id", DataType::Integer, FieldMeaning::Identifier)
1308 .intent_hint(IntentHint::Primary(Intent::Browse))
1309 .intent_hint(IntentHint::Exclude(Intent::Collect));
1310
1311 let json = serde_json::to_string_pretty(&service).unwrap();
1312 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1313 assert_eq!(service, parsed);
1314 assert_eq!(parsed.intent_hints.len(), 2);
1315 }
1316
1317 #[test]
1318 fn validate_passes_with_valid_intent_hints() {
1319 let service = ServiceDef::new("order")
1320 .field("id", DataType::Integer, FieldMeaning::Identifier)
1321 .intent_hint(IntentHint::Primary(Intent::Browse))
1322 .intent_hint(IntentHint::Exclude(Intent::Collect));
1323
1324 let warnings = service.validate().unwrap();
1325 assert!(
1326 warnings.is_empty(),
1327 "expected no warnings, got: {warnings:?}"
1328 );
1329 }
1330
1331 #[test]
1332 fn validate_warns_conflicting_intent_hints() {
1333 let service = ServiceDef::new("order")
1334 .intent_hint(IntentHint::Primary(Intent::Browse))
1335 .intent_hint(IntentHint::Exclude(Intent::Browse));
1336
1337 let warnings = service.validate().unwrap();
1338 assert!(warnings.contains(&Warning::ConflictingIntentHints {
1339 intent: "browse".into()
1340 }));
1341 }
1342
1343 #[test]
1344 fn validate_warns_multiple_primary_intent_hints() {
1345 let service = ServiceDef::new("order")
1346 .intent_hint(IntentHint::Primary(Intent::Browse))
1347 .intent_hint(IntentHint::Primary(Intent::Focus));
1348
1349 let warnings = service.validate().unwrap();
1350 assert!(warnings.contains(&Warning::MultiplePrimaryIntentHints));
1351 }
1352
1353 #[test]
1354 fn validate_warns_both_conflicting_and_multiple_primary() {
1355 let service = ServiceDef::new("order")
1356 .intent_hint(IntentHint::Primary(Intent::Browse))
1357 .intent_hint(IntentHint::Primary(Intent::Focus))
1358 .intent_hint(IntentHint::Exclude(Intent::Browse));
1359
1360 let warnings = service.validate().unwrap();
1361 assert!(warnings.contains(&Warning::ConflictingIntentHints {
1362 intent: "browse".into()
1363 }));
1364 assert!(warnings.contains(&Warning::MultiplePrimaryIntentHints));
1365 }
1366
1367 #[test]
1368 fn validate_no_warning_for_single_primary() {
1369 let service = ServiceDef::new("order").intent_hint(IntentHint::Primary(Intent::Browse));
1370
1371 let warnings = service.validate().unwrap();
1372 assert!(
1373 warnings.is_empty(),
1374 "expected no warnings, got: {warnings:?}"
1375 );
1376 }
1377
1378 #[test]
1379 fn service_def_json_schema_includes_intent_hints() {
1380 let schema = schemars::schema_for!(ServiceDef);
1381 let value = schema.to_value();
1382 let props = value
1383 .get("properties")
1384 .expect("ServiceDef schema must have properties");
1385 let obj = props.as_object().unwrap();
1386 assert!(
1387 obj.contains_key("intent_hints"),
1388 "missing 'intent_hints' property"
1389 );
1390 }
1391
1392 #[test]
1395 fn full_service_with_intent_hints() {
1396 let machine = StateMachine::new("order_lifecycle")
1397 .initial("draft")
1398 .state(StateDef::new("draft").display_name("Draft"))
1399 .state(
1400 StateDef::new("submitted")
1401 .display_name("Submitted")
1402 .on_enter(vec!["validate_inventory"]),
1403 )
1404 .state(
1405 StateDef::new("shipped")
1406 .display_name("Shipped")
1407 .final_state(),
1408 )
1409 .state(
1410 StateDef::new("cancelled")
1411 .display_name("Cancelled")
1412 .final_state(),
1413 )
1414 .transition(Transition::new("draft", "submit", "submitted").guard("has_items"))
1415 .transition(
1416 Transition::new("submitted", "ship", "shipped").guard("inventory_fulfilled"),
1417 )
1418 .transition(
1419 Transition::new("draft", "cancel", "cancelled").guard("cancellation_allowed"),
1420 )
1421 .transition(
1422 Transition::new("submitted", "cancel", "cancelled").guard("cancellation_allowed"),
1423 );
1424
1425 let service = ServiceDef::new("order")
1426 .display_name("Order")
1427 .description("Full order management with all features including intent hints")
1428 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1429 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
1430 .field("total", DataType::Float, FieldMeaning::Money)
1431 .field("status", DataType::String, FieldMeaning::Status)
1432 .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
1433 .guard(GuardDef::new("has_items"))
1434 .guard(GuardDef::new("inventory_fulfilled"))
1435 .guard(GuardDef::new("cancellation_allowed"))
1436 .action(
1437 ActionDef::new("submit_order")
1438 .precondition("has_items")
1439 .transition_trigger("submit"),
1440 )
1441 .action(
1442 ActionDef::new("ship_order")
1443 .precondition("inventory_fulfilled")
1444 .transition_trigger("ship"),
1445 )
1446 .action(
1447 ActionDef::new("cancel_order")
1448 .precondition("cancellation_allowed")
1449 .transition_trigger("cancel"),
1450 )
1451 .belongs_to("customer", "customer")
1452 .has_many("line_items", "order_line_item")
1453 .has_one("invoice", "invoice")
1454 .intent_hint(IntentHint::Primary(Intent::Process))
1455 .intent_hint(IntentHint::Exclude(Intent::Summarize))
1456 .state_machine(machine);
1457
1458 let warnings = service.validate().unwrap();
1460 assert!(
1461 warnings.is_empty(),
1462 "expected no warnings, got: {warnings:?}"
1463 );
1464
1465 assert_eq!(service.fields.len(), 5);
1467 assert_eq!(service.guards.len(), 3);
1468 assert_eq!(service.actions.len(), 3);
1469 assert_eq!(service.relationships.len(), 3);
1470 assert_eq!(service.intent_hints.len(), 2);
1471 assert!(service.state_machine.is_some());
1472
1473 assert_eq!(
1475 service.intent_hints[0],
1476 IntentHint::Primary(Intent::Process)
1477 );
1478 assert_eq!(
1479 service.intent_hints[1],
1480 IntentHint::Exclude(Intent::Summarize)
1481 );
1482
1483 let json = serde_json::to_string_pretty(&service).unwrap();
1485 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1486 assert_eq!(service, parsed);
1487 }
1488
1489 fn order_meta() -> ModelMetadata {
1490 ModelMetadata {
1491 name: "order".to_string(),
1492 display_name: None,
1493 table: Some("orders".to_string()),
1494 fields: vec![
1495 FieldMetadata {
1496 name: "id".into(),
1497 column_type: "i32".into(),
1498 is_primary_key: true,
1499 is_nullable: false,
1500 },
1501 FieldMetadata {
1502 name: "total".into(),
1503 column_type: "f64".into(),
1504 is_primary_key: false,
1505 is_nullable: false,
1506 },
1507 FieldMetadata {
1508 name: "status".into(),
1509 column_type: "String".into(),
1510 is_primary_key: false,
1511 is_nullable: false,
1512 },
1513 FieldMetadata {
1514 name: "notes".into(),
1515 column_type: "Option<String>".into(),
1516 is_primary_key: false,
1517 is_nullable: true,
1518 },
1519 FieldMetadata {
1520 name: "created_at".into(),
1521 column_type: "DateTime<Utc>".into(),
1522 is_primary_key: false,
1523 is_nullable: false,
1524 },
1525 ],
1526 }
1527 }
1528
1529 #[test]
1530 fn from_model_basic() {
1531 let meta = order_meta();
1532 let def = ServiceDef::from_model(&meta);
1533 assert_eq!(def.name, "order");
1534 assert_eq!(def.display_name.as_deref(), Some("Order"));
1535 assert_eq!(def.fields.len(), 5);
1536 }
1537
1538 #[test]
1539 fn from_model_system_fields_read_only() {
1540 let meta = order_meta();
1541 let def = ServiceDef::from_model(&meta);
1542 let id = def.fields.iter().find(|f| f.name == "id").unwrap();
1543 assert!(!id.writable, "id must be read-only");
1544 let created_at = def.fields.iter().find(|f| f.name == "created_at").unwrap();
1545 assert!(!created_at.writable, "created_at must be read-only");
1546 let total = def.fields.iter().find(|f| f.name == "total").unwrap();
1547 assert!(total.writable, "total must be writable");
1548 }
1549
1550 #[test]
1551 fn from_model_nullable_to_required() {
1552 let meta = order_meta();
1553 let def = ServiceDef::from_model(&meta);
1554 let notes = def.fields.iter().find(|f| f.name == "notes").unwrap();
1555 assert!(!notes.required, "nullable field must have required: false");
1556 let total = def.fields.iter().find(|f| f.name == "total").unwrap();
1557 assert!(
1558 total.required,
1559 "non-nullable field must have required: true"
1560 );
1561 }
1562
1563 #[test]
1564 fn from_model_display_name_override() {
1565 let meta = ModelMetadata {
1566 name: "order".to_string(),
1567 display_name: Some("Custom Name".to_string()),
1568 table: None,
1569 fields: vec![],
1570 };
1571 let def = ServiceDef::from_model(&meta);
1572 assert_eq!(def.display_name.as_deref(), Some("Custom Name"));
1573 }
1574
1575 #[test]
1576 fn from_model_snake_to_title() {
1577 let meta = ModelMetadata {
1578 name: "order_item".to_string(),
1579 display_name: None,
1580 table: None,
1581 fields: vec![],
1582 };
1583 let def = ServiceDef::from_model(&meta);
1584 assert_eq!(def.display_name.as_deref(), Some("Order Item"));
1585 }
1586
1587 #[test]
1588 fn round_trip_model_to_intents() {
1589 use crate::derive::derive_intents;
1590
1591 let meta = order_meta();
1592 let def = ServiceDef::from_model(&meta);
1593 let intents = derive_intents(&def);
1594 assert!(
1595 !intents.is_empty(),
1596 "derive_intents must produce at least one intent score"
1597 );
1598 }
1599}