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