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 #[serde(default)]
96 pub creatable: bool,
97 #[serde(default)]
99 pub updatable: bool,
100 #[serde(default)]
102 pub deletable: bool,
103 #[serde(skip_serializing_if = "Option::is_none")]
106 pub mcp_write_ability: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
110 pub table: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub soft_delete_column: Option<String>,
114}
115
116impl ServiceDef {
117 pub fn new(name: impl Into<String>) -> Self {
119 Self {
120 name: name.into(),
121 display_name: None,
122 description: None,
123 fields: Vec::new(),
124 actions: Vec::new(),
125 guards: Vec::new(),
126 relationships: Vec::new(),
127 intent_hints: Vec::new(),
128 state_machine: None,
129 mcp_exposed: false,
130 tenant_column: None,
131 mcp_ability: None,
132 creatable: false,
133 updatable: false,
134 deletable: false,
135 mcp_write_ability: None,
136 table: None,
137 soft_delete_column: None,
138 }
139 }
140
141 pub fn display_name(mut self, name: impl Into<String>) -> Self {
143 self.display_name = Some(name.into());
144 self
145 }
146
147 pub fn description(mut self, desc: impl Into<String>) -> Self {
149 self.description = Some(desc.into());
150 self
151 }
152
153 pub fn mcp_exposed(mut self, exposed: bool) -> Self {
155 self.mcp_exposed = exposed;
156 self
157 }
158
159 pub fn tenant_column(mut self, col: impl Into<String>) -> Self {
162 self.tenant_column = Some(col.into());
163 self
164 }
165
166 pub fn mcp_ability(mut self, ability: impl Into<String>) -> Self {
169 self.mcp_ability = Some(ability.into());
170 self
171 }
172
173 pub fn creatable(mut self, yes: bool) -> Self {
175 self.creatable = yes;
176 self
177 }
178
179 pub fn updatable(mut self, yes: bool) -> Self {
181 self.updatable = yes;
182 self
183 }
184
185 pub fn deletable(mut self, yes: bool) -> Self {
188 self.deletable = yes;
189 self
190 }
191
192 pub fn mcp_write_ability(mut self, ability: impl Into<String>) -> Self {
194 self.mcp_write_ability = Some(ability.into());
195 self
196 }
197
198 pub fn table(mut self, table: impl Into<String>) -> Self {
200 self.table = Some(table.into());
201 self
202 }
203
204 pub fn soft_delete_column(mut self, col: impl Into<String>) -> Self {
206 self.soft_delete_column = Some(col.into());
207 self
208 }
209
210 pub fn resolved_table(&self) -> String {
216 self.table
217 .clone()
218 .unwrap_or_else(|| format!("{}s", self.name.to_lowercase()))
219 }
220
221 pub fn resolved_soft_delete_column(&self) -> &str {
224 self.soft_delete_column.as_deref().unwrap_or("deleted_at")
225 }
226
227 pub fn is_server_injected_field(&self, field: &FieldDef) -> bool {
237 matches!(
238 field.meaning,
239 FieldMeaning::Identifier | FieldMeaning::CreatedAt
240 ) || self
241 .tenant_column
242 .as_deref()
243 .map(|tc| tc == field.name)
244 .unwrap_or(false)
245 }
246
247 pub fn is_write_excluded_field(&self, field: &FieldDef, exclude_sm_status: bool) -> bool {
255 if self.is_server_injected_field(field) {
257 return true;
258 }
259 if matches!(field.meaning, FieldMeaning::UpdatedAt) {
261 return true;
262 }
263 if matches!(field.meaning, FieldMeaning::Sensitive) {
265 return true;
266 }
267 if field.is_list {
269 return true;
270 }
271 if exclude_sm_status && matches!(field.meaning, FieldMeaning::Status) {
273 return true;
274 }
275 if !field.writable {
279 return true;
280 }
281 false
282 }
283
284 pub fn field(
286 mut self,
287 name: impl Into<String>,
288 data_type: DataType,
289 meaning: FieldMeaning,
290 ) -> Self {
291 self.fields.push(FieldDef {
292 name: name.into(),
293 data_type,
294 meaning,
295 required: true,
296 is_list: false,
297 readable: true,
298 writable: true,
299 render_hint: None,
300 });
301 self
302 }
303
304 pub fn field_with_hint(
310 mut self,
311 name: impl Into<String>,
312 data_type: DataType,
313 meaning: FieldMeaning,
314 hint: RenderHint,
315 ) -> Self {
316 self.fields.push(FieldDef {
317 name: name.into(),
318 data_type,
319 meaning,
320 required: true,
321 is_list: false,
322 readable: true,
323 writable: true,
324 render_hint: Some(hint),
325 });
326 self
327 }
328
329 pub fn optional_field(
331 mut self,
332 name: impl Into<String>,
333 data_type: DataType,
334 meaning: FieldMeaning,
335 ) -> Self {
336 self.fields.push(FieldDef {
337 name: name.into(),
338 data_type,
339 meaning,
340 required: false,
341 is_list: false,
342 readable: true,
343 writable: true,
344 render_hint: None,
345 });
346 self
347 }
348
349 pub fn list_field(
351 mut self,
352 name: impl Into<String>,
353 data_type: DataType,
354 meaning: FieldMeaning,
355 ) -> Self {
356 self.fields.push(FieldDef {
357 name: name.into(),
358 data_type,
359 meaning,
360 required: true,
361 is_list: true,
362 readable: true,
363 writable: true,
364 render_hint: None,
365 });
366 self
367 }
368
369 pub fn read_only_field(
373 mut self,
374 name: impl Into<String>,
375 data_type: DataType,
376 meaning: FieldMeaning,
377 ) -> Self {
378 self.fields.push(FieldDef {
379 name: name.into(),
380 data_type,
381 meaning,
382 required: true,
383 is_list: false,
384 readable: true,
385 writable: false,
386 render_hint: None,
387 });
388 self
389 }
390
391 pub fn write_only_field(
395 mut self,
396 name: impl Into<String>,
397 data_type: DataType,
398 meaning: FieldMeaning,
399 ) -> Self {
400 self.fields.push(FieldDef {
401 name: name.into(),
402 data_type,
403 meaning,
404 required: true,
405 is_list: false,
406 readable: false,
407 writable: true,
408 render_hint: None,
409 });
410 self
411 }
412
413 pub fn action(mut self, action: ActionDef) -> Self {
415 self.actions.push(action);
416 self
417 }
418
419 pub fn guard(mut self, guard: GuardDef) -> Self {
421 self.guards.push(guard);
422 self
423 }
424
425 pub fn relationship(mut self, rel: RelationshipDef) -> Self {
427 self.relationships.push(rel);
428 self
429 }
430
431 pub fn belongs_to(self, name: impl Into<String>, target: impl Into<String>) -> Self {
433 self.relationship(RelationshipDef::new(name, target, Cardinality::ManyToOne))
434 }
435
436 pub fn has_many(self, name: impl Into<String>, target: impl Into<String>) -> Self {
438 self.relationship(RelationshipDef::new(name, target, Cardinality::OneToMany))
439 }
440
441 pub fn has_one(self, name: impl Into<String>, target: impl Into<String>) -> Self {
443 self.relationship(RelationshipDef::new(name, target, Cardinality::OneToOne))
444 }
445
446 pub fn belongs_to_many(self, name: impl Into<String>, target: impl Into<String>) -> Self {
448 self.relationship(RelationshipDef::new(name, target, Cardinality::ManyToMany))
449 }
450
451 pub fn intent_hint(mut self, hint: IntentHint) -> Self {
453 self.intent_hints.push(hint);
454 self
455 }
456
457 pub fn state_machine(mut self, machine: StateMachine) -> Self {
459 self.state_machine = Some(machine);
460 self
461 }
462
463 pub fn from_model(meta: &ModelMetadata) -> Self {
469 let display = meta
470 .display_name
471 .clone()
472 .unwrap_or_else(|| snake_to_title(&meta.name));
473
474 let mut def = Self::new(&meta.name).display_name(display);
475
476 for field in &meta.fields {
477 let data_type = DataType::from_column_type(&field.column_type);
478 let meaning = infer_meaning(&field.name);
479
480 let is_system = matches!(field.name.as_str(), "id" | "created_at" | "updated_at")
481 || field.is_primary_key;
482
483 def.fields.push(FieldDef {
484 name: field.name.clone(),
485 data_type,
486 meaning,
487 required: !field.is_nullable,
488 is_list: false,
489 readable: true,
490 writable: !is_system,
491 render_hint: None,
492 });
493 }
494
495 def
496 }
497
498 pub fn validate(&self) -> Result<Vec<Warning>, crate::Error> {
506 let mut warnings = Vec::new();
507
508 if (self.creatable || self.updatable || self.deletable) && self.mcp_write_ability.is_none()
511 {
512 return Err(crate::Error::Validation(format!(
513 "projection '{}' enables create/update/delete but declares no mcp_write_ability",
514 self.name
515 )));
516 }
517
518 if let Some(ref sm) = self.state_machine {
520 warnings.extend(sm.validate()?);
521 }
522
523 let declared_guards: HashSet<&str> = self.guards.iter().map(|g| g.name.as_str()).collect();
525
526 for action in &self.actions {
528 for precondition in &action.preconditions {
529 if !declared_guards.contains(precondition.as_str()) {
530 return Err(crate::Error::Validation(format!(
531 "action '{}' references undefined guard '{}'",
532 action.name, precondition
533 )));
534 }
535 }
536 }
537
538 if let Some(ref sm) = self.state_machine {
540 for transition in &sm.transitions {
541 if let Some(ref guard) = transition.guard {
542 if !declared_guards.contains(guard.as_str()) {
543 return Err(crate::Error::Validation(format!(
544 "transition '{}' -> '{}' references undefined guard '{}'",
545 transition.from, transition.to, guard
546 )));
547 }
548 }
549 }
550 }
551
552 if let Some(ref sm) = self.state_machine {
554 let event_names: HashSet<&str> =
555 sm.transitions.iter().map(|t| t.event.as_str()).collect();
556 for action in &self.actions {
557 if let Some(ref trigger) = action.transition_trigger {
558 if !event_names.contains(trigger.as_str()) {
559 return Err(crate::Error::Validation(format!(
560 "action '{}' has transition_trigger '{}' that does not match any state machine event",
561 action.name, trigger
562 )));
563 }
564 }
565 }
566 }
567
568 if self.state_machine.is_some() {
574 for action in &self.actions {
575 if action.transition_trigger.is_some() {
576 crate::executor::derive_transition_plan(self, &action.name)?;
577 }
578 }
579 }
580
581 let mut referenced_guards: HashSet<&str> = HashSet::new();
583 for action in &self.actions {
584 for precondition in &action.preconditions {
585 referenced_guards.insert(precondition.as_str());
586 }
587 }
588 if let Some(ref sm) = self.state_machine {
589 for transition in &sm.transitions {
590 if let Some(ref guard) = transition.guard {
591 referenced_guards.insert(guard.as_str());
592 }
593 }
594 }
595 for guard in &self.guards {
596 if !referenced_guards.contains(guard.name.as_str()) {
597 warnings.push(Warning::UnusedGuard(guard.name.clone()));
598 }
599 }
600
601 if self.state_machine.is_none() {
603 for action in &self.actions {
604 if action.transition_trigger.is_some() {
605 warnings.push(Warning::TransitionTriggerWithoutStateMachine(
606 action.name.clone(),
607 ));
608 }
609 }
610 }
611
612 {
614 let mut seen = HashSet::new();
615 for rel in &self.relationships {
616 if !seen.insert(rel.name.as_str()) {
617 warnings.push(Warning::DuplicateRelationship(rel.name.clone()));
618 }
619 }
620 }
621
622 for rel in &self.relationships {
624 if rel.cardinality == Cardinality::ManyToMany && rel.foreign_key.is_some() {
625 warnings.push(Warning::ManyToManyWithForeignKey {
626 relationship: rel.name.clone(),
627 });
628 }
629 }
630
631 {
633 let mut primaries = HashSet::new();
634 let mut excludes = HashSet::new();
635 let mut primary_count = 0u32;
636
637 for hint in &self.intent_hints {
638 match hint {
639 IntentHint::Primary(intent) => {
640 primary_count += 1;
641 let serialized = serde_json::to_string(intent)
642 .unwrap_or_default()
643 .trim_matches('"')
644 .to_string();
645 primaries.insert(serialized);
646 }
647 IntentHint::Exclude(intent) => {
648 let serialized = serde_json::to_string(intent)
649 .unwrap_or_default()
650 .trim_matches('"')
651 .to_string();
652 excludes.insert(serialized);
653 }
654 }
655 }
656
657 for intent_name in primaries.intersection(&excludes) {
658 warnings.push(Warning::ConflictingIntentHints {
659 intent: intent_name.clone(),
660 });
661 }
662
663 if primary_count > 1 {
664 warnings.push(Warning::MultiplePrimaryIntentHints);
665 }
666 }
667
668 Ok(warnings)
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 #[test]
677 fn service_def_builder_chain() {
678 let service = ServiceDef::new("order")
679 .display_name("Order")
680 .description("Manages customer orders")
681 .field("id", DataType::Integer, FieldMeaning::Identifier)
682 .field("total", DataType::Float, FieldMeaning::Money)
683 .field("status", DataType::String, FieldMeaning::Status)
684 .optional_field("notes", DataType::String, FieldMeaning::FreeText)
685 .list_field("tags", DataType::String, FieldMeaning::Category);
686
687 assert_eq!(service.name, "order");
688 assert_eq!(service.display_name.as_deref(), Some("Order"));
689 assert_eq!(
690 service.description.as_deref(),
691 Some("Manages customer orders")
692 );
693 assert_eq!(service.fields.len(), 5);
694
695 assert!(service.fields[0].required);
697 assert!(!service.fields[0].is_list);
698
699 assert!(!service.fields[3].required);
701 assert!(!service.fields[3].is_list);
702
703 assert!(service.fields[4].required);
705 assert!(service.fields[4].is_list);
706 }
707
708 #[test]
709 fn service_def_minimal() {
710 let service = ServiceDef::new("user");
711 assert_eq!(service.name, "user");
712 assert!(service.display_name.is_none());
713 assert!(service.description.is_none());
714 assert!(service.fields.is_empty());
715 }
716
717 #[test]
718 fn service_def_serde_round_trip() {
719 let service = ServiceDef::new("order")
720 .display_name("Order")
721 .field("id", DataType::Integer, FieldMeaning::Identifier)
722 .field("total", DataType::Float, FieldMeaning::Money)
723 .optional_field("notes", DataType::String, FieldMeaning::FreeText);
724
725 let json = serde_json::to_string(&service).unwrap();
726 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
727 assert_eq!(service, parsed);
728 }
729
730 #[test]
731 fn service_def_json_omits_none_fields() {
732 let service = ServiceDef::new("order");
733 let json = serde_json::to_string(&service).unwrap();
734 assert!(!json.contains("display_name"));
735 assert!(!json.contains("description"));
736 }
737
738 #[test]
739 fn service_def_multiple_fields() {
740 let service = ServiceDef::new("product")
741 .field("id", DataType::Integer, FieldMeaning::Identifier)
742 .field("name", DataType::String, FieldMeaning::EntityName)
743 .field("price", DataType::Float, FieldMeaning::Money)
744 .field("sku", DataType::String, FieldMeaning::Custom("sku".into()))
745 .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
746
747 assert_eq!(service.fields.len(), 5);
748 assert_eq!(service.fields[0].name, "id");
750 assert_eq!(service.fields[1].name, "name");
751 assert_eq!(service.fields[2].name, "price");
752 assert_eq!(service.fields[3].name, "sku");
753 assert_eq!(service.fields[4].name, "created_at");
754 }
755
756 #[test]
757 fn field_with_hint_attaches_render_hint() {
758 let service = ServiceDef::new("profile")
759 .field("id", DataType::Integer, FieldMeaning::Identifier)
760 .field_with_hint(
761 "avatar",
762 DataType::String,
763 FieldMeaning::ImageUrl,
764 RenderHint::AltText("User avatar".into()),
765 );
766
767 assert_eq!(service.fields[0].render_hint, None);
768 assert_eq!(
769 service.fields[1].render_hint,
770 Some(RenderHint::AltText("User avatar".into()))
771 );
772 assert!(service.fields[1].required);
774 assert!(service.fields[1].readable);
775 assert!(service.fields[1].writable);
776 assert!(!service.fields[1].is_list);
777 }
778
779 #[test]
780 fn service_def_json_structure() {
781 let service = ServiceDef::new("order")
782 .display_name("Order")
783 .description("Customer orders")
784 .field("id", DataType::Integer, FieldMeaning::Identifier)
785 .optional_field("notes", DataType::String, FieldMeaning::FreeText);
786
787 let json = serde_json::to_string(&service).unwrap();
788 let value: serde_json::Value = serde_json::from_str(&json).unwrap();
789
790 assert!(value.get("name").is_some());
791 assert!(value.get("display_name").is_some());
792 assert!(value.get("description").is_some());
793 assert!(value.get("fields").is_some());
794
795 let fields = value["fields"].as_array().unwrap();
796 assert_eq!(fields.len(), 2);
797 }
798
799 #[test]
800 fn order_service_example() {
801 let service = ServiceDef::new("order")
802 .display_name("Order")
803 .description("Manages customer orders and fulfillment")
804 .field("id", DataType::Integer, FieldMeaning::Identifier)
805 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
806 .field("total", DataType::Float, FieldMeaning::Money)
807 .field("status", DataType::String, FieldMeaning::Status)
808 .field("email", DataType::String, FieldMeaning::Email)
809 .field("notes", DataType::String, FieldMeaning::FreeText)
810 .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
811 .field("updated_at", DataType::DateTime, FieldMeaning::UpdatedAt);
812
813 assert_eq!(service.fields.len(), 8);
814 assert_eq!(service.fields[2].meaning, FieldMeaning::Money);
815 assert_eq!(service.fields[3].meaning, FieldMeaning::Status);
816
817 let json = serde_json::to_string(&service).unwrap();
819 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
820 assert_eq!(service, parsed);
821 }
822
823 use crate::state::{StateDef, StateMachine, Transition};
826
827 #[test]
828 fn service_def_with_state_machine() {
829 let machine = StateMachine::new("order_lifecycle")
830 .initial("draft")
831 .state(StateDef::new("draft"))
832 .state(StateDef::new("completed").final_state())
833 .transition(Transition::new("draft", "complete", "completed"));
834
835 let service = ServiceDef::new("order")
836 .field("id", DataType::Integer, FieldMeaning::Identifier)
837 .state_machine(machine);
838
839 assert!(service.state_machine.is_some());
840 let sm = service.state_machine.as_ref().unwrap();
841 assert_eq!(sm.states.len(), 2);
842 assert_eq!(sm.transitions.len(), 1);
843 }
844
845 #[test]
846 fn service_def_state_machine_serde_round_trip() {
847 let machine = StateMachine::new("order_lifecycle")
848 .initial("draft")
849 .state(StateDef::new("draft").display_name("Draft"))
850 .state(
851 StateDef::new("completed")
852 .display_name("Completed")
853 .final_state(),
854 )
855 .transition(
856 Transition::new("draft", "complete", "completed")
857 .guard("is_valid")
858 .actions(vec!["notify"]),
859 );
860
861 let service = ServiceDef::new("order")
862 .display_name("Order")
863 .field("id", DataType::Integer, FieldMeaning::Identifier)
864 .field("status", DataType::String, FieldMeaning::Status)
865 .state_machine(machine);
866
867 let json = serde_json::to_string_pretty(&service).unwrap();
868 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
869 assert_eq!(service, parsed);
870 }
871
872 #[test]
873 fn service_def_without_state_machine_json() {
874 let service =
875 ServiceDef::new("user").field("id", DataType::Integer, FieldMeaning::Identifier);
876
877 let json = serde_json::to_string(&service).unwrap();
878 assert!(!json.contains("state_machine"));
879 }
880
881 #[test]
882 fn order_service_full_example() {
883 let machine = StateMachine::new("order_lifecycle")
884 .display_name("Order Lifecycle")
885 .description("Tracks an order from creation to fulfillment")
886 .initial("draft")
887 .state(
888 StateDef::new("draft")
889 .display_name("Draft")
890 .description("Order is being prepared"),
891 )
892 .state(
893 StateDef::new("submitted")
894 .display_name("Submitted")
895 .on_enter(vec!["validate_inventory", "calculate_totals"]),
896 )
897 .state(
898 StateDef::new("processing")
899 .display_name("Processing")
900 .on_enter(vec!["charge_payment", "reserve_inventory"]),
901 )
902 .state(
903 StateDef::new("shipped")
904 .display_name("Shipped")
905 .on_enter(vec!["generate_tracking", "notify_customer"]),
906 )
907 .state(
908 StateDef::new("delivered")
909 .display_name("Delivered")
910 .final_state(),
911 )
912 .state(
913 StateDef::new("cancelled")
914 .display_name("Cancelled")
915 .final_state()
916 .on_enter(vec!["refund_payment", "release_inventory"]),
917 )
918 .transition(
919 Transition::new("draft", "submit", "submitted")
920 .guard("has_items")
921 .description("Customer submits the order"),
922 )
923 .transition(
924 Transition::new("submitted", "process", "processing")
925 .guard("payment_valid")
926 .actions(vec!["lock_prices"]),
927 )
928 .transition(
929 Transition::new("processing", "ship", "shipped").guard("inventory_fulfilled"),
930 )
931 .transition(Transition::new("shipped", "deliver", "delivered"))
932 .transition(Transition::new("draft", "cancel", "cancelled"))
933 .transition(
934 Transition::new("submitted", "cancel", "cancelled").guard("cancellation_allowed"),
935 )
936 .transition(
937 Transition::new("processing", "cancel", "cancelled")
938 .guard("cancellation_allowed")
939 .actions(vec!["reverse_payment"]),
940 );
941
942 let service = ServiceDef::new("order")
943 .display_name("Order")
944 .description("Manages customer orders and fulfillment")
945 .field("id", DataType::Integer, FieldMeaning::Identifier)
946 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
947 .field("total", DataType::Float, FieldMeaning::Money)
948 .field("status", DataType::String, FieldMeaning::Status)
949 .field("email", DataType::String, FieldMeaning::Email)
950 .field("notes", DataType::String, FieldMeaning::FreeText)
951 .field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
952 .field("updated_at", DataType::DateTime, FieldMeaning::UpdatedAt)
953 .state_machine(machine);
954
955 assert_eq!(service.fields.len(), 8);
957
958 let sm = service.state_machine.as_ref().unwrap();
960 assert_eq!(sm.states.len(), 6);
961 assert_eq!(sm.transitions.len(), 7);
962 assert_eq!(sm.initial_state, "draft");
963
964 let warnings = sm.validate().unwrap();
966 assert!(warnings.is_empty());
967
968 let json = serde_json::to_string_pretty(&service).unwrap();
970 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
971 assert_eq!(service, parsed);
972 }
973
974 #[test]
975 fn service_def_json_schema() {
976 let schema = schemars::schema_for!(ServiceDef);
977 let value = schema.to_value();
978 let props = value
979 .get("properties")
980 .expect("ServiceDef schema must have properties");
981 let obj = props.as_object().unwrap();
982 assert!(obj.contains_key("name"), "missing 'name' property");
983 assert!(obj.contains_key("fields"), "missing 'fields' property");
984 assert!(
985 obj.contains_key("state_machine"),
986 "missing 'state_machine' property"
987 );
988 }
989
990 #[test]
993 fn read_only_field_builder() {
994 let service = ServiceDef::new("order")
995 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
996 .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
997
998 assert_eq!(service.fields.len(), 2);
999 for f in &service.fields {
1000 assert!(f.readable);
1001 assert!(!f.writable);
1002 assert!(f.required);
1003 assert!(!f.is_list);
1004 }
1005 }
1006
1007 #[test]
1008 fn write_only_field_builder() {
1009 let service = ServiceDef::new("user").write_only_field(
1010 "password",
1011 DataType::String,
1012 FieldMeaning::Sensitive,
1013 );
1014
1015 assert_eq!(service.fields.len(), 1);
1016 let f = &service.fields[0];
1017 assert!(!f.readable);
1018 assert!(f.writable);
1019 assert!(f.required);
1020 assert!(!f.is_list);
1021 }
1022
1023 #[test]
1024 fn mixed_access_fields_serde_round_trip() {
1025 let service = ServiceDef::new("user")
1026 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1027 .field("name", DataType::String, FieldMeaning::EntityName)
1028 .write_only_field("password", DataType::String, FieldMeaning::Sensitive)
1029 .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt);
1030
1031 let json = serde_json::to_string(&service).unwrap();
1032 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1033 assert_eq!(service, parsed);
1034
1035 assert!(parsed.fields[0].readable);
1037 assert!(!parsed.fields[0].writable);
1038 assert!(parsed.fields[1].readable);
1039 assert!(parsed.fields[1].writable);
1040 assert!(!parsed.fields[2].readable);
1041 assert!(parsed.fields[2].writable);
1042 assert!(parsed.fields[3].readable);
1043 assert!(!parsed.fields[3].writable);
1044 }
1045
1046 #[test]
1047 fn existing_field_builders_default_read_write() {
1048 let service = ServiceDef::new("order")
1049 .field("id", DataType::Integer, FieldMeaning::Identifier)
1050 .optional_field("notes", DataType::String, FieldMeaning::FreeText)
1051 .list_field("tags", DataType::String, FieldMeaning::Category);
1052
1053 for f in &service.fields {
1054 assert!(f.readable, "field '{}' should be readable", f.name);
1055 assert!(f.writable, "field '{}' should be writable", f.name);
1056 }
1057 }
1058
1059 use crate::action::{ActionDef, GuardDef, InputDef};
1062 use crate::state::Warning;
1063
1064 #[test]
1065 fn service_def_with_actions_and_guards_builder() {
1066 let service = ServiceDef::new("order")
1067 .guard(GuardDef::new("has_items"))
1068 .guard(GuardDef::new("payment_valid"))
1069 .action(
1070 ActionDef::new("submit_order")
1071 .precondition("has_items")
1072 .precondition("payment_valid"),
1073 )
1074 .action(ActionDef::new("update_notes"));
1075
1076 assert_eq!(service.guards.len(), 2);
1077 assert_eq!(service.actions.len(), 2);
1078 assert_eq!(service.actions[0].name, "submit_order");
1079 assert_eq!(service.actions[1].name, "update_notes");
1080 }
1081
1082 #[test]
1083 fn service_def_serde_round_trip_with_actions_guards() {
1084 let service = ServiceDef::new("order")
1085 .field("id", DataType::Integer, FieldMeaning::Identifier)
1086 .guard(GuardDef::new("has_items").display_name("Has Items"))
1087 .action(
1088 ActionDef::new("submit")
1089 .input(InputDef::new(
1090 "order_id",
1091 DataType::Integer,
1092 FieldMeaning::Identifier,
1093 ))
1094 .precondition("has_items")
1095 .effect("notify"),
1096 );
1097
1098 let json = serde_json::to_string_pretty(&service).unwrap();
1099 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1100 assert_eq!(service, parsed);
1101 }
1102
1103 #[test]
1104 fn service_def_json_omits_empty_actions_guards() {
1105 let service = ServiceDef::new("user");
1106 let json = serde_json::to_string(&service).unwrap();
1107 assert!(!json.contains("actions"));
1108 assert!(!json.contains("guards"));
1109 }
1110
1111 #[test]
1112 fn validate_passes_valid_service() {
1113 let machine = StateMachine::new("order_lifecycle")
1114 .initial("draft")
1115 .state(StateDef::new("draft"))
1116 .state(StateDef::new("submitted").final_state())
1117 .transition(Transition::new("draft", "submit", "submitted").guard("has_items"));
1118
1119 let service = ServiceDef::new("order")
1120 .field("id", DataType::Integer, FieldMeaning::Identifier)
1121 .guard(GuardDef::new("has_items"))
1122 .action(
1123 ActionDef::new("submit_order")
1124 .precondition("has_items")
1125 .transition_trigger("submit"),
1126 )
1127 .state_machine(machine);
1128
1129 let warnings = service.validate().unwrap();
1130 assert!(warnings.is_empty());
1131 }
1132
1133 #[test]
1134 fn validate_catches_undefined_action_precondition() {
1135 let service = ServiceDef::new("order")
1136 .guard(GuardDef::new("has_items"))
1137 .action(ActionDef::new("submit").precondition("nonexistent_guard"));
1138
1139 let result = service.validate();
1140 assert!(result.is_err());
1141 let err = result.unwrap_err().to_string();
1142 assert!(err.contains("nonexistent_guard"));
1143 assert!(err.contains("submit"));
1144 }
1145
1146 #[test]
1147 fn validate_catches_undefined_transition_guard() {
1148 let machine = StateMachine::new("lifecycle")
1149 .initial("draft")
1150 .state(StateDef::new("draft"))
1151 .state(StateDef::new("done").final_state())
1152 .transition(Transition::new("draft", "finish", "done").guard("undefined_guard"));
1153
1154 let service = ServiceDef::new("order").state_machine(machine);
1155
1156 let result = service.validate();
1157 assert!(result.is_err());
1158 let err = result.unwrap_err().to_string();
1159 assert!(err.contains("undefined_guard"));
1160 }
1161
1162 #[test]
1163 fn validate_catches_unmatched_transition_trigger() {
1164 let machine = StateMachine::new("lifecycle")
1165 .initial("draft")
1166 .state(StateDef::new("draft"))
1167 .state(StateDef::new("done").final_state())
1168 .transition(Transition::new("draft", "finish", "done"));
1169
1170 let service = ServiceDef::new("order")
1171 .action(ActionDef::new("submit").transition_trigger("nonexistent_event"))
1172 .state_machine(machine);
1173
1174 let result = service.validate();
1175 assert!(result.is_err());
1176 let err = result.unwrap_err().to_string();
1177 assert!(err.contains("nonexistent_event"));
1178 }
1179
1180 fn well_formed_order_service() -> ServiceDef {
1183 let machine = StateMachine::new("order_lifecycle")
1184 .initial("draft")
1185 .state(StateDef::new("draft"))
1186 .state(StateDef::new("submitted"))
1187 .state(StateDef::new("approved"))
1188 .state(StateDef::new("cancelled").final_state())
1189 .state(StateDef::new("shipped"))
1190 .state(StateDef::new("delivered").final_state())
1191 .transition(Transition::new("draft", "submit", "submitted"))
1192 .transition(Transition::new("submitted", "approve", "approved").guard("is_manager"))
1193 .transition(Transition::new("approved", "ship", "shipped"))
1194 .transition(Transition::new("draft", "cancel", "cancelled"))
1195 .transition(Transition::new("submitted", "cancel", "cancelled"));
1196
1197 ServiceDef::new("order")
1198 .guard(GuardDef::new("is_manager"))
1199 .state_machine(machine)
1200 .action(ActionDef::new("submit").transition_trigger("submit"))
1201 .action(
1202 ActionDef::new("approve")
1203 .transition_trigger("approve")
1204 .precondition("is_manager"),
1205 )
1206 .action(ActionDef::new("ship").transition_trigger("ship"))
1207 .action(ActionDef::new("cancel").transition_trigger("cancel"))
1208 }
1209
1210 #[test]
1211 fn validate_rejects_undeclared_trigger() {
1212 let machine = StateMachine::new("lifecycle")
1213 .initial("draft")
1214 .state(StateDef::new("draft"))
1215 .state(StateDef::new("done").final_state())
1216 .transition(Transition::new("draft", "finish", "done"));
1217
1218 let service = ServiceDef::new("order")
1219 .state_machine(machine)
1220 .action(ActionDef::new("submit").transition_trigger("typo_event"));
1221
1222 let err = service.validate().unwrap_err();
1223 assert!(err.to_string().contains("typo_event"));
1226 }
1227
1228 #[test]
1229 fn validate_accepts_well_formed_order_service() {
1230 let service = well_formed_order_service();
1231 assert!(service.validate().is_ok());
1232 }
1233
1234 #[test]
1235 fn validate_round_trips_derivation() {
1236 let service = well_formed_order_service();
1237 assert!(service.validate().is_ok());
1239 for action in &service.actions {
1242 if action.transition_trigger.is_some() {
1243 assert!(
1244 crate::executor::derive_transition_plan(&service, &action.name).is_ok(),
1245 "derivation must succeed for action '{}'",
1246 action.name
1247 );
1248 }
1249 }
1250 }
1251
1252 #[test]
1253 fn validate_rejects_ambiguous_fan_out_at_registration() {
1254 let machine = StateMachine::new("lifecycle")
1256 .initial("a")
1257 .state(StateDef::new("a"))
1258 .state(StateDef::new("b"))
1259 .state(StateDef::new("c"))
1260 .state(StateDef::new("d"))
1261 .transition(Transition::new("a", "split", "b"))
1262 .transition(Transition::new("c", "split", "d"));
1263
1264 let service = ServiceDef::new("order")
1265 .state_machine(machine)
1266 .action(ActionDef::new("split").transition_trigger("split"));
1267
1268 let err = service.validate().unwrap_err();
1269 assert!(matches!(err, crate::Error::AmbiguousTransition { ref event } if event == "split"));
1270 }
1271
1272 #[test]
1273 fn validate_warns_unused_guards() {
1274 let service = ServiceDef::new("order")
1275 .guard(GuardDef::new("used_guard"))
1276 .guard(GuardDef::new("unused_guard"))
1277 .action(ActionDef::new("submit").precondition("used_guard"));
1278
1279 let warnings = service.validate().unwrap();
1280 assert_eq!(warnings.len(), 1);
1281 assert!(warnings.contains(&Warning::UnusedGuard("unused_guard".into())));
1282 }
1283
1284 #[test]
1285 fn validate_warns_transition_trigger_without_state_machine() {
1286 let service =
1287 ServiceDef::new("order").action(ActionDef::new("submit").transition_trigger("submit"));
1288
1289 let warnings = service.validate().unwrap();
1290 assert_eq!(warnings.len(), 1);
1291 assert!(
1292 warnings.contains(&Warning::TransitionTriggerWithoutStateMachine(
1293 "submit".into()
1294 ))
1295 );
1296 }
1297
1298 #[test]
1299 fn validate_delegates_to_state_machine_validate() {
1300 let machine = StateMachine::new("lifecycle")
1302 .initial("nonexistent")
1303 .state(StateDef::new("a").final_state());
1304
1305 let service = ServiceDef::new("order").state_machine(machine);
1306
1307 let result = service.validate();
1308 assert!(result.is_err());
1309 let err = result.unwrap_err().to_string();
1310 assert!(err.contains("nonexistent"));
1311 }
1312
1313 #[test]
1314 fn validate_without_state_machine_or_actions_passes_clean() {
1315 let service =
1316 ServiceDef::new("simple").field("id", DataType::Integer, FieldMeaning::Identifier);
1317
1318 let warnings = service.validate().unwrap();
1319 assert!(warnings.is_empty());
1320 }
1321
1322 #[test]
1323 fn full_order_service_with_guards_actions_validates_clean() {
1324 let machine = StateMachine::new("order_lifecycle")
1325 .display_name("Order Lifecycle")
1326 .initial("draft")
1327 .state(StateDef::new("draft").display_name("Draft"))
1328 .state(StateDef::new("submitted").display_name("Submitted"))
1329 .state(StateDef::new("processing").display_name("Processing"))
1330 .state(
1331 StateDef::new("shipped")
1332 .display_name("Shipped")
1333 .final_state(),
1334 )
1335 .state(
1336 StateDef::new("cancelled")
1337 .display_name("Cancelled")
1338 .final_state(),
1339 )
1340 .transition(Transition::new("draft", "submit", "submitted").guard("has_items"))
1341 .transition(
1342 Transition::new("submitted", "process", "processing").guard("payment_valid"),
1343 )
1344 .transition(
1345 Transition::new("processing", "ship", "shipped").guard("inventory_fulfilled"),
1346 )
1347 .transition(
1348 Transition::new("draft", "cancel", "cancelled").guard("cancellation_allowed"),
1349 )
1350 .transition(
1351 Transition::new("submitted", "cancel", "cancelled").guard("cancellation_allowed"),
1352 );
1353
1354 let service = ServiceDef::new("order")
1355 .display_name("Order")
1356 .description("Full order management")
1357 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1358 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
1359 .field("total", DataType::Float, FieldMeaning::Money)
1360 .field("status", DataType::String, FieldMeaning::Status)
1361 .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
1362 .guard(GuardDef::new("has_items").display_name("Has Items"))
1363 .guard(GuardDef::new("payment_valid").display_name("Payment Valid"))
1364 .guard(GuardDef::new("inventory_fulfilled").display_name("Inventory Fulfilled"))
1365 .guard(GuardDef::new("cancellation_allowed").display_name("Cancellation Allowed"))
1366 .action(
1367 ActionDef::new("submit_order")
1368 .display_name("Submit Order")
1369 .input(InputDef::new(
1370 "order_id",
1371 DataType::Integer,
1372 FieldMeaning::Identifier,
1373 ))
1374 .precondition("has_items")
1375 .effect("notify_customer")
1376 .transition_trigger("submit"),
1377 )
1378 .action(
1379 ActionDef::new("process_order")
1380 .precondition("payment_valid")
1381 .transition_trigger("process"),
1382 )
1383 .action(
1384 ActionDef::new("ship_order")
1385 .precondition("inventory_fulfilled")
1386 .transition_trigger("ship"),
1387 )
1388 .action(
1389 ActionDef::new("cancel_order")
1390 .precondition("cancellation_allowed")
1391 .effect("refund_payment")
1392 .transition_trigger("cancel"),
1393 )
1394 .state_machine(machine);
1395
1396 let warnings = service.validate().unwrap();
1398 assert!(
1399 warnings.is_empty(),
1400 "expected no warnings, got: {warnings:?}"
1401 );
1402
1403 assert_eq!(service.fields.len(), 5);
1405 assert_eq!(service.guards.len(), 4);
1406 assert_eq!(service.actions.len(), 4);
1407 assert!(service.state_machine.is_some());
1408
1409 let json = serde_json::to_string_pretty(&service).unwrap();
1411 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1412 assert_eq!(service, parsed);
1413 }
1414
1415 #[test]
1416 fn service_def_json_schema_includes_actions_guards() {
1417 let schema = schemars::schema_for!(ServiceDef);
1418 let value = schema.to_value();
1419 let props = value
1420 .get("properties")
1421 .expect("ServiceDef schema must have properties");
1422 let obj = props.as_object().unwrap();
1423 assert!(obj.contains_key("actions"), "missing 'actions' property");
1424 assert!(obj.contains_key("guards"), "missing 'guards' property");
1425 }
1426
1427 use crate::relationship::{Cardinality, NavigationHint, RelationshipDef};
1430
1431 #[test]
1432 fn service_def_with_relationships_builder() {
1433 let service = ServiceDef::new("order").relationship(
1434 RelationshipDef::new("customer", "customer", Cardinality::ManyToOne)
1435 .foreign_key("customer_id"),
1436 );
1437
1438 assert_eq!(service.relationships.len(), 1);
1439 assert_eq!(service.relationships[0].name, "customer");
1440 assert_eq!(service.relationships[0].target, "customer");
1441 assert_eq!(service.relationships[0].cardinality, Cardinality::ManyToOne);
1442 }
1443
1444 #[test]
1445 fn service_def_belongs_to_convenience() {
1446 let service = ServiceDef::new("order").belongs_to("customer", "customer");
1447
1448 assert_eq!(service.relationships.len(), 1);
1449 let rel = &service.relationships[0];
1450 assert_eq!(rel.name, "customer");
1451 assert_eq!(rel.target, "customer");
1452 assert_eq!(rel.cardinality, Cardinality::ManyToOne);
1453 assert_eq!(rel.navigation, NavigationHint::Link);
1454 }
1455
1456 #[test]
1457 fn service_def_has_many_convenience() {
1458 let service = ServiceDef::new("order").has_many("line_items", "order_line_item");
1459
1460 assert_eq!(service.relationships.len(), 1);
1461 let rel = &service.relationships[0];
1462 assert_eq!(rel.name, "line_items");
1463 assert_eq!(rel.target, "order_line_item");
1464 assert_eq!(rel.cardinality, Cardinality::OneToMany);
1465 assert_eq!(rel.navigation, NavigationHint::Nested);
1466 }
1467
1468 #[test]
1469 fn service_def_has_one_convenience() {
1470 let service = ServiceDef::new("user").has_one("profile", "user_profile");
1471
1472 assert_eq!(service.relationships.len(), 1);
1473 let rel = &service.relationships[0];
1474 assert_eq!(rel.name, "profile");
1475 assert_eq!(rel.target, "user_profile");
1476 assert_eq!(rel.cardinality, Cardinality::OneToOne);
1477 assert_eq!(rel.navigation, NavigationHint::Inline);
1478 }
1479
1480 #[test]
1481 fn service_def_belongs_to_many_convenience() {
1482 let service = ServiceDef::new("post").belongs_to_many("tags", "tag");
1483
1484 assert_eq!(service.relationships.len(), 1);
1485 let rel = &service.relationships[0];
1486 assert_eq!(rel.name, "tags");
1487 assert_eq!(rel.target, "tag");
1488 assert_eq!(rel.cardinality, Cardinality::ManyToMany);
1489 assert_eq!(rel.navigation, NavigationHint::Nested);
1490 }
1491
1492 #[test]
1493 fn service_def_json_omits_empty_relationships() {
1494 let service = ServiceDef::new("user");
1495 let json = serde_json::to_string(&service).unwrap();
1496 assert!(!json.contains("relationships"));
1497 }
1498
1499 #[test]
1500 fn service_def_relationships_serde_round_trip() {
1501 let service = ServiceDef::new("order")
1502 .field("id", DataType::Integer, FieldMeaning::Identifier)
1503 .belongs_to("customer", "customer")
1504 .has_many("line_items", "order_line_item")
1505 .has_one("invoice", "invoice")
1506 .belongs_to_many("tags", "tag");
1507
1508 let json = serde_json::to_string_pretty(&service).unwrap();
1509 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1510 assert_eq!(service, parsed);
1511 assert_eq!(parsed.relationships.len(), 4);
1512 }
1513
1514 #[test]
1517 fn validate_warns_duplicate_relationship_names() {
1518 let service = ServiceDef::new("order")
1519 .belongs_to("customer", "customer")
1520 .belongs_to("customer", "other_customer");
1521
1522 let warnings = service.validate().unwrap();
1523 assert!(warnings.contains(&Warning::DuplicateRelationship("customer".into())));
1524 }
1525
1526 #[test]
1527 fn validate_warns_many_to_many_with_foreign_key() {
1528 let service = ServiceDef::new("post").relationship(
1529 RelationshipDef::new("tags", "tag", Cardinality::ManyToMany).foreign_key("tag_id"),
1530 );
1531
1532 let warnings = service.validate().unwrap();
1533 assert!(warnings.contains(&Warning::ManyToManyWithForeignKey {
1534 relationship: "tags".into()
1535 }));
1536 }
1537
1538 #[test]
1539 fn validate_passes_with_valid_relationships() {
1540 let service = ServiceDef::new("order")
1541 .field("id", DataType::Integer, FieldMeaning::Identifier)
1542 .belongs_to("customer", "customer")
1543 .has_many("line_items", "order_line_item");
1544
1545 let warnings = service.validate().unwrap();
1546 assert!(
1547 warnings.is_empty(),
1548 "expected no warnings, got: {warnings:?}"
1549 );
1550 }
1551
1552 #[test]
1553 fn order_service_with_relationships_full_example() {
1554 let machine = StateMachine::new("order_lifecycle")
1555 .initial("draft")
1556 .state(StateDef::new("draft").display_name("Draft"))
1557 .state(
1558 StateDef::new("submitted")
1559 .display_name("Submitted")
1560 .final_state(),
1561 )
1562 .transition(Transition::new("draft", "submit", "submitted").guard("has_items"));
1563
1564 let service = ServiceDef::new("order")
1565 .display_name("Order")
1566 .description("Full order management with relationships")
1567 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1568 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
1569 .field("total", DataType::Float, FieldMeaning::Money)
1570 .field("status", DataType::String, FieldMeaning::Status)
1571 .guard(GuardDef::new("has_items"))
1572 .action(
1573 ActionDef::new("submit_order")
1574 .precondition("has_items")
1575 .transition_trigger("submit"),
1576 )
1577 .belongs_to("customer", "customer")
1578 .has_many("line_items", "order_line_item")
1579 .has_one("invoice", "invoice")
1580 .state_machine(machine);
1581
1582 let warnings = service.validate().unwrap();
1584 assert!(
1585 warnings.is_empty(),
1586 "expected no warnings, got: {warnings:?}"
1587 );
1588
1589 assert_eq!(service.fields.len(), 4);
1591 assert_eq!(service.guards.len(), 1);
1592 assert_eq!(service.actions.len(), 1);
1593 assert_eq!(service.relationships.len(), 3);
1594 assert!(service.state_machine.is_some());
1595
1596 let json = serde_json::to_string_pretty(&service).unwrap();
1598 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1599 assert_eq!(service, parsed);
1600 }
1601
1602 #[test]
1603 fn mcp_exposed_defaults_false_when_absent() {
1604 let json = r#"{"name":"order","fields":[]}"#;
1605 let parsed: ServiceDef = serde_json::from_str(json).unwrap();
1606 assert!(!parsed.mcp_exposed);
1607 }
1608
1609 #[test]
1610 fn mcp_exposed_builder_sets_flag() {
1611 let s = ServiceDef::new("order").mcp_exposed(true);
1612 assert!(s.mcp_exposed);
1613 }
1614
1615 #[test]
1616 fn tenant_and_ability_default_none_when_absent() {
1617 let json = r#"{"name":"order","fields":[]}"#;
1618 let parsed: ServiceDef = serde_json::from_str(json).unwrap();
1619 assert!(parsed.tenant_column.is_none());
1620 assert!(parsed.mcp_ability.is_none());
1621 }
1622
1623 #[test]
1624 fn tenant_column_and_mcp_ability_builder_sets_values() {
1625 let s = ServiceDef::new("order")
1626 .tenant_column("tenant_id")
1627 .mcp_ability("view-orders");
1628 assert_eq!(s.tenant_column, Some("tenant_id".to_string()));
1629 assert_eq!(s.mcp_ability, Some("view-orders".to_string()));
1630 }
1631
1632 #[test]
1633 fn tenant_column_and_mcp_ability_skip_serializing_when_none() {
1634 let s = ServiceDef::new("order").field(
1635 "id",
1636 crate::field::DataType::Integer,
1637 crate::field::FieldMeaning::Identifier,
1638 );
1639 let json = serde_json::to_string(&s).unwrap();
1640 assert!(
1641 !json.contains("tenant_column"),
1642 "tenant_column should be absent when None"
1643 );
1644 assert!(
1645 !json.contains("mcp_ability"),
1646 "mcp_ability should be absent when None"
1647 );
1648 }
1649
1650 #[test]
1651 fn service_def_json_schema_includes_relationships() {
1652 let schema = schemars::schema_for!(ServiceDef);
1653 let value = schema.to_value();
1654 let props = value
1655 .get("properties")
1656 .expect("ServiceDef schema must have properties");
1657 let obj = props.as_object().unwrap();
1658 assert!(
1659 obj.contains_key("relationships"),
1660 "missing 'relationships' property"
1661 );
1662 }
1663
1664 use crate::intent::{Intent, IntentHint};
1667
1668 #[test]
1669 fn service_def_new_has_empty_intent_hints() {
1670 let service = ServiceDef::new("order");
1671 assert!(service.intent_hints.is_empty());
1672 }
1673
1674 #[test]
1675 fn service_def_intent_hint_builder() {
1676 let service = ServiceDef::new("order")
1677 .intent_hint(IntentHint::Primary(Intent::Browse))
1678 .intent_hint(IntentHint::Exclude(Intent::Process));
1679
1680 assert_eq!(service.intent_hints.len(), 2);
1681 assert_eq!(service.intent_hints[0], IntentHint::Primary(Intent::Browse));
1682 assert_eq!(
1683 service.intent_hints[1],
1684 IntentHint::Exclude(Intent::Process)
1685 );
1686 }
1687
1688 #[test]
1689 fn service_def_json_omits_empty_intent_hints() {
1690 let service = ServiceDef::new("user");
1691 let json = serde_json::to_string(&service).unwrap();
1692 assert!(!json.contains("intent_hints"));
1693 }
1694
1695 #[test]
1696 fn service_def_intent_hints_serde_round_trip() {
1697 let service = ServiceDef::new("order")
1698 .field("id", DataType::Integer, FieldMeaning::Identifier)
1699 .intent_hint(IntentHint::Primary(Intent::Browse))
1700 .intent_hint(IntentHint::Exclude(Intent::Collect));
1701
1702 let json = serde_json::to_string_pretty(&service).unwrap();
1703 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1704 assert_eq!(service, parsed);
1705 assert_eq!(parsed.intent_hints.len(), 2);
1706 }
1707
1708 #[test]
1709 fn validate_passes_with_valid_intent_hints() {
1710 let service = ServiceDef::new("order")
1711 .field("id", DataType::Integer, FieldMeaning::Identifier)
1712 .intent_hint(IntentHint::Primary(Intent::Browse))
1713 .intent_hint(IntentHint::Exclude(Intent::Collect));
1714
1715 let warnings = service.validate().unwrap();
1716 assert!(
1717 warnings.is_empty(),
1718 "expected no warnings, got: {warnings:?}"
1719 );
1720 }
1721
1722 #[test]
1723 fn validate_warns_conflicting_intent_hints() {
1724 let service = ServiceDef::new("order")
1725 .intent_hint(IntentHint::Primary(Intent::Browse))
1726 .intent_hint(IntentHint::Exclude(Intent::Browse));
1727
1728 let warnings = service.validate().unwrap();
1729 assert!(warnings.contains(&Warning::ConflictingIntentHints {
1730 intent: "browse".into()
1731 }));
1732 }
1733
1734 #[test]
1735 fn validate_warns_multiple_primary_intent_hints() {
1736 let service = ServiceDef::new("order")
1737 .intent_hint(IntentHint::Primary(Intent::Browse))
1738 .intent_hint(IntentHint::Primary(Intent::Focus));
1739
1740 let warnings = service.validate().unwrap();
1741 assert!(warnings.contains(&Warning::MultiplePrimaryIntentHints));
1742 }
1743
1744 #[test]
1745 fn validate_warns_both_conflicting_and_multiple_primary() {
1746 let service = ServiceDef::new("order")
1747 .intent_hint(IntentHint::Primary(Intent::Browse))
1748 .intent_hint(IntentHint::Primary(Intent::Focus))
1749 .intent_hint(IntentHint::Exclude(Intent::Browse));
1750
1751 let warnings = service.validate().unwrap();
1752 assert!(warnings.contains(&Warning::ConflictingIntentHints {
1753 intent: "browse".into()
1754 }));
1755 assert!(warnings.contains(&Warning::MultiplePrimaryIntentHints));
1756 }
1757
1758 #[test]
1759 fn validate_no_warning_for_single_primary() {
1760 let service = ServiceDef::new("order").intent_hint(IntentHint::Primary(Intent::Browse));
1761
1762 let warnings = service.validate().unwrap();
1763 assert!(
1764 warnings.is_empty(),
1765 "expected no warnings, got: {warnings:?}"
1766 );
1767 }
1768
1769 #[test]
1770 fn service_def_json_schema_includes_intent_hints() {
1771 let schema = schemars::schema_for!(ServiceDef);
1772 let value = schema.to_value();
1773 let props = value
1774 .get("properties")
1775 .expect("ServiceDef schema must have properties");
1776 let obj = props.as_object().unwrap();
1777 assert!(
1778 obj.contains_key("intent_hints"),
1779 "missing 'intent_hints' property"
1780 );
1781 }
1782
1783 #[test]
1786 fn full_service_with_intent_hints() {
1787 let machine = StateMachine::new("order_lifecycle")
1788 .initial("draft")
1789 .state(StateDef::new("draft").display_name("Draft"))
1790 .state(
1791 StateDef::new("submitted")
1792 .display_name("Submitted")
1793 .on_enter(vec!["validate_inventory"]),
1794 )
1795 .state(
1796 StateDef::new("shipped")
1797 .display_name("Shipped")
1798 .final_state(),
1799 )
1800 .state(
1801 StateDef::new("cancelled")
1802 .display_name("Cancelled")
1803 .final_state(),
1804 )
1805 .transition(Transition::new("draft", "submit", "submitted").guard("has_items"))
1806 .transition(
1807 Transition::new("submitted", "ship", "shipped").guard("inventory_fulfilled"),
1808 )
1809 .transition(
1810 Transition::new("draft", "cancel", "cancelled").guard("cancellation_allowed"),
1811 )
1812 .transition(
1813 Transition::new("submitted", "cancel", "cancelled").guard("cancellation_allowed"),
1814 );
1815
1816 let service = ServiceDef::new("order")
1817 .display_name("Order")
1818 .description("Full order management with all features including intent hints")
1819 .read_only_field("id", DataType::Integer, FieldMeaning::Identifier)
1820 .field("customer_id", DataType::Integer, FieldMeaning::ForeignKey)
1821 .field("total", DataType::Float, FieldMeaning::Money)
1822 .field("status", DataType::String, FieldMeaning::Status)
1823 .read_only_field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
1824 .guard(GuardDef::new("has_items"))
1825 .guard(GuardDef::new("inventory_fulfilled"))
1826 .guard(GuardDef::new("cancellation_allowed"))
1827 .action(
1828 ActionDef::new("submit_order")
1829 .precondition("has_items")
1830 .transition_trigger("submit"),
1831 )
1832 .action(
1833 ActionDef::new("ship_order")
1834 .precondition("inventory_fulfilled")
1835 .transition_trigger("ship"),
1836 )
1837 .action(
1838 ActionDef::new("cancel_order")
1839 .precondition("cancellation_allowed")
1840 .transition_trigger("cancel"),
1841 )
1842 .belongs_to("customer", "customer")
1843 .has_many("line_items", "order_line_item")
1844 .has_one("invoice", "invoice")
1845 .intent_hint(IntentHint::Primary(Intent::Process))
1846 .intent_hint(IntentHint::Exclude(Intent::Summarize))
1847 .state_machine(machine);
1848
1849 let warnings = service.validate().unwrap();
1851 assert!(
1852 warnings.is_empty(),
1853 "expected no warnings, got: {warnings:?}"
1854 );
1855
1856 assert_eq!(service.fields.len(), 5);
1858 assert_eq!(service.guards.len(), 3);
1859 assert_eq!(service.actions.len(), 3);
1860 assert_eq!(service.relationships.len(), 3);
1861 assert_eq!(service.intent_hints.len(), 2);
1862 assert!(service.state_machine.is_some());
1863
1864 assert_eq!(
1866 service.intent_hints[0],
1867 IntentHint::Primary(Intent::Process)
1868 );
1869 assert_eq!(
1870 service.intent_hints[1],
1871 IntentHint::Exclude(Intent::Summarize)
1872 );
1873
1874 let json = serde_json::to_string_pretty(&service).unwrap();
1876 let parsed: ServiceDef = serde_json::from_str(&json).unwrap();
1877 assert_eq!(service, parsed);
1878 }
1879
1880 fn order_meta() -> ModelMetadata {
1881 ModelMetadata {
1882 name: "order".to_string(),
1883 display_name: None,
1884 table: Some("orders".to_string()),
1885 fields: vec![
1886 FieldMetadata {
1887 name: "id".into(),
1888 column_type: "i32".into(),
1889 is_primary_key: true,
1890 is_nullable: false,
1891 },
1892 FieldMetadata {
1893 name: "total".into(),
1894 column_type: "f64".into(),
1895 is_primary_key: false,
1896 is_nullable: false,
1897 },
1898 FieldMetadata {
1899 name: "status".into(),
1900 column_type: "String".into(),
1901 is_primary_key: false,
1902 is_nullable: false,
1903 },
1904 FieldMetadata {
1905 name: "notes".into(),
1906 column_type: "Option<String>".into(),
1907 is_primary_key: false,
1908 is_nullable: true,
1909 },
1910 FieldMetadata {
1911 name: "created_at".into(),
1912 column_type: "DateTime<Utc>".into(),
1913 is_primary_key: false,
1914 is_nullable: false,
1915 },
1916 ],
1917 }
1918 }
1919
1920 #[test]
1921 fn from_model_basic() {
1922 let meta = order_meta();
1923 let def = ServiceDef::from_model(&meta);
1924 assert_eq!(def.name, "order");
1925 assert_eq!(def.display_name.as_deref(), Some("Order"));
1926 assert_eq!(def.fields.len(), 5);
1927 }
1928
1929 #[test]
1930 fn from_model_system_fields_read_only() {
1931 let meta = order_meta();
1932 let def = ServiceDef::from_model(&meta);
1933 let id = def.fields.iter().find(|f| f.name == "id").unwrap();
1934 assert!(!id.writable, "id must be read-only");
1935 let created_at = def.fields.iter().find(|f| f.name == "created_at").unwrap();
1936 assert!(!created_at.writable, "created_at must be read-only");
1937 let total = def.fields.iter().find(|f| f.name == "total").unwrap();
1938 assert!(total.writable, "total must be writable");
1939 }
1940
1941 #[test]
1942 fn from_model_nullable_to_required() {
1943 let meta = order_meta();
1944 let def = ServiceDef::from_model(&meta);
1945 let notes = def.fields.iter().find(|f| f.name == "notes").unwrap();
1946 assert!(!notes.required, "nullable field must have required: false");
1947 let total = def.fields.iter().find(|f| f.name == "total").unwrap();
1948 assert!(
1949 total.required,
1950 "non-nullable field must have required: true"
1951 );
1952 }
1953
1954 #[test]
1955 fn from_model_display_name_override() {
1956 let meta = ModelMetadata {
1957 name: "order".to_string(),
1958 display_name: Some("Custom Name".to_string()),
1959 table: None,
1960 fields: vec![],
1961 };
1962 let def = ServiceDef::from_model(&meta);
1963 assert_eq!(def.display_name.as_deref(), Some("Custom Name"));
1964 }
1965
1966 #[test]
1967 fn from_model_snake_to_title() {
1968 let meta = ModelMetadata {
1969 name: "order_item".to_string(),
1970 display_name: None,
1971 table: None,
1972 fields: vec![],
1973 };
1974 let def = ServiceDef::from_model(&meta);
1975 assert_eq!(def.display_name.as_deref(), Some("Order Item"));
1976 }
1977
1978 #[test]
1979 fn round_trip_model_to_intents() {
1980 use crate::derive::derive_intents;
1981
1982 let meta = order_meta();
1983 let def = ServiceDef::from_model(&meta);
1984 let intents = derive_intents(&def);
1985 assert!(
1986 !intents.is_empty(),
1987 "derive_intents must produce at least one intent score"
1988 );
1989 }
1990
1991 #[test]
1994 fn crud_flags_default_false() {
1995 let def = ServiceDef::new("order");
1996 assert!(!def.creatable);
1997 assert!(!def.updatable);
1998 assert!(!def.deletable);
1999 assert!(def.mcp_write_ability.is_none());
2000 assert!(def.table.is_none());
2001 assert!(def.soft_delete_column.is_none());
2002 }
2003
2004 #[test]
2005 fn crud_builders_set_flags() {
2006 let def = ServiceDef::new("order")
2007 .creatable(true)
2008 .updatable(true)
2009 .deletable(true);
2010 assert!(def.creatable);
2011 assert!(def.updatable);
2012 assert!(def.deletable);
2013 }
2014
2015 #[test]
2016 fn write_surface_builders_set_fields() {
2017 let def = ServiceDef::new("order")
2018 .mcp_write_ability("manage-orders")
2019 .table("orders")
2020 .soft_delete_column("deleted_at");
2021 assert_eq!(def.mcp_write_ability.as_deref(), Some("manage-orders"));
2022 assert_eq!(def.table.as_deref(), Some("orders"));
2023 assert_eq!(def.soft_delete_column.as_deref(), Some("deleted_at"));
2024 }
2025
2026 #[test]
2027 fn validate_rejects_creatable_without_write_ability() {
2028 let def = ServiceDef::new("order").creatable(true);
2029 let err = def.validate().unwrap_err();
2030 assert!(matches!(err, crate::Error::Validation(_)));
2031 assert!(
2032 err.to_string().contains("mcp_write_ability"),
2033 "error message must mention 'mcp_write_ability'; got: {err}"
2034 );
2035 }
2036
2037 #[test]
2038 fn validate_rejects_updatable_without_write_ability() {
2039 let def = ServiceDef::new("order").updatable(true);
2040 let err = def.validate().unwrap_err();
2041 assert!(matches!(err, crate::Error::Validation(_)));
2042 assert!(
2043 err.to_string().contains("mcp_write_ability"),
2044 "error message must mention 'mcp_write_ability'; got: {err}"
2045 );
2046 }
2047
2048 #[test]
2049 fn validate_rejects_deletable_without_write_ability() {
2050 let def = ServiceDef::new("order").deletable(true);
2051 let err = def.validate().unwrap_err();
2052 assert!(matches!(err, crate::Error::Validation(_)));
2053 assert!(
2054 err.to_string().contains("mcp_write_ability"),
2055 "error message must mention 'mcp_write_ability'; got: {err}"
2056 );
2057 }
2058
2059 #[test]
2060 fn validate_accepts_crud_with_write_ability() {
2061 let def = ServiceDef::new("order")
2062 .creatable(true)
2063 .updatable(true)
2064 .deletable(true)
2065 .mcp_write_ability("manage-orders");
2066 assert!(def.validate().is_ok());
2067 }
2068
2069 #[test]
2070 fn validate_allows_read_only_without_write_ability() {
2071 let def = ServiceDef::new("order");
2073 assert!(def.validate().is_ok());
2074 }
2075
2076 #[test]
2077 fn serde_round_trip_includes_crud_surface() {
2078 let def = ServiceDef::new("order")
2079 .creatable(true)
2080 .updatable(true)
2081 .deletable(true)
2082 .mcp_write_ability("manage-orders")
2083 .table("orders")
2084 .soft_delete_column("deleted_at");
2085 let json = serde_json::to_string(&def).unwrap();
2086 let back: ServiceDef = serde_json::from_str(&json).unwrap();
2087 assert!(back.creatable && back.updatable && back.deletable);
2088 assert_eq!(back.mcp_write_ability.as_deref(), Some("manage-orders"));
2089 assert_eq!(back.table.as_deref(), Some("orders"));
2090 assert_eq!(back.soft_delete_column.as_deref(), Some("deleted_at"));
2091 }
2092
2093 #[test]
2096 fn resolved_table_default() {
2097 assert_eq!(ServiceDef::new("order").resolved_table(), "orders");
2098 }
2099
2100 #[test]
2101 fn resolved_table_default_lowercases() {
2102 assert_eq!(ServiceDef::new("Order").resolved_table(), "orders");
2103 }
2104
2105 #[test]
2106 fn resolved_table_explicit_override() {
2107 assert_eq!(
2108 ServiceDef::new("order")
2109 .table("purchase_orders")
2110 .resolved_table(),
2111 "purchase_orders"
2112 );
2113 }
2114
2115 #[test]
2116 fn resolved_soft_delete_column_default() {
2117 assert_eq!(
2118 ServiceDef::new("order").resolved_soft_delete_column(),
2119 "deleted_at"
2120 );
2121 }
2122
2123 #[test]
2124 fn resolved_soft_delete_column_explicit_override() {
2125 assert_eq!(
2126 ServiceDef::new("order")
2127 .soft_delete_column("removed_at")
2128 .resolved_soft_delete_column(),
2129 "removed_at"
2130 );
2131 }
2132
2133 fn mk_field(name: &str, meaning: FieldMeaning) -> FieldDef {
2136 FieldDef {
2137 name: name.to_string(),
2138 data_type: DataType::String,
2139 meaning,
2140 required: false,
2141 is_list: false,
2142 readable: true,
2143 writable: true,
2144 render_hint: None,
2145 }
2146 }
2147
2148 #[test]
2149 fn server_injected_identifier() {
2150 assert!(ServiceDef::new("order")
2151 .is_server_injected_field(&mk_field("id", FieldMeaning::Identifier)));
2152 }
2153
2154 #[test]
2155 fn server_injected_created_at() {
2156 assert!(ServiceDef::new("order")
2157 .is_server_injected_field(&mk_field("created_at", FieldMeaning::CreatedAt)));
2158 }
2159
2160 #[test]
2161 fn server_injected_tenant_column() {
2162 let svc = ServiceDef::new("order").tenant_column("tenant_id");
2163 assert!(svc.is_server_injected_field(&mk_field("tenant_id", FieldMeaning::ForeignKey)));
2164 }
2165
2166 #[test]
2167 fn server_injected_false_for_regular_field() {
2168 let svc = ServiceDef::new("order").tenant_column("tenant_id");
2169 assert!(!svc.is_server_injected_field(&mk_field("customer_name", FieldMeaning::EntityName)));
2170 }
2171
2172 fn mk_field_typed(name: &str, dt: DataType, meaning: FieldMeaning, is_list: bool) -> FieldDef {
2175 FieldDef {
2176 name: name.to_string(),
2177 data_type: dt,
2178 meaning,
2179 required: true,
2180 is_list,
2181 readable: true,
2182 writable: true,
2183 render_hint: None,
2184 }
2185 }
2186
2187 #[test]
2188 fn is_write_excluded_field_gates() {
2189 use crate::state::{StateDef as SD, StateMachine as SM, Transition as TR};
2190
2191 let minimal_sm = SM::new("lifecycle")
2192 .initial("a")
2193 .state(SD::new("a"))
2194 .state(SD::new("b").final_state())
2195 .transition(TR::new("a", "go", "b"));
2196
2197 struct Case {
2199 name: &'static str,
2200 dt: DataType,
2201 meaning: FieldMeaning,
2202 is_list: bool,
2203 sm_present: bool,
2204 expected: bool,
2205 }
2206
2207 let cases = [
2208 Case {
2210 name: "id",
2211 dt: DataType::Integer,
2212 meaning: FieldMeaning::Identifier,
2213 is_list: false,
2214 sm_present: false,
2215 expected: true,
2216 },
2217 Case {
2219 name: "created_at",
2220 dt: DataType::DateTime,
2221 meaning: FieldMeaning::CreatedAt,
2222 is_list: false,
2223 sm_present: false,
2224 expected: true,
2225 },
2226 Case {
2229 name: "updated_at",
2230 dt: DataType::DateTime,
2231 meaning: FieldMeaning::UpdatedAt,
2232 is_list: false,
2233 sm_present: false,
2234 expected: true,
2235 },
2236 Case {
2238 name: "password",
2239 dt: DataType::String,
2240 meaning: FieldMeaning::Sensitive,
2241 is_list: false,
2242 sm_present: false,
2243 expected: true,
2244 },
2245 Case {
2247 name: "tags",
2248 dt: DataType::String,
2249 meaning: FieldMeaning::Category,
2250 is_list: true,
2251 sm_present: false,
2252 expected: true,
2253 },
2254 Case {
2256 name: "status",
2257 dt: DataType::String,
2258 meaning: FieldMeaning::Status,
2259 is_list: false,
2260 sm_present: true,
2261 expected: true,
2262 },
2263 Case {
2265 name: "status",
2266 dt: DataType::String,
2267 meaning: FieldMeaning::Status,
2268 is_list: false,
2269 sm_present: false,
2270 expected: false,
2271 },
2272 Case {
2274 name: "notes",
2275 dt: DataType::String,
2276 meaning: FieldMeaning::FreeText,
2277 is_list: false,
2278 sm_present: false,
2279 expected: false,
2280 },
2281 Case {
2282 name: "notes",
2283 dt: DataType::String,
2284 meaning: FieldMeaning::FreeText,
2285 is_list: false,
2286 sm_present: true,
2287 expected: false,
2288 },
2289 ];
2290
2291 for c in &cases {
2292 let field = mk_field_typed(c.name, c.dt, c.meaning.clone(), c.is_list);
2293 let svc = if c.sm_present {
2294 ServiceDef::new("order").state_machine(minimal_sm.clone())
2295 } else {
2296 ServiceDef::new("order")
2297 };
2298 let got = svc.is_write_excluded_field(&field, c.sm_present);
2299 assert_eq!(
2300 got, c.expected,
2301 "field '{}' (meaning={:?}, is_list={}, sm_present={}): expected excluded={}, got={}",
2302 c.name, c.meaning, c.is_list, c.sm_present, c.expected, got
2303 );
2304 }
2305
2306 let tenant_field =
2308 mk_field_typed("org_id", DataType::Integer, FieldMeaning::ForeignKey, false);
2309 let svc_with_tenant = ServiceDef::new("order").tenant_column("org_id");
2310 assert!(
2311 svc_with_tenant.is_write_excluded_field(&tenant_field, false),
2312 "tenant column must be write-excluded regardless of SM flag"
2313 );
2314 }
2315
2316 #[test]
2321 fn is_write_excluded_field_excludes_read_only() {
2322 let svc = ServiceDef::new("order")
2323 .field("customer_name", DataType::String, FieldMeaning::EntityName)
2324 .read_only_field("total", DataType::Float, FieldMeaning::Money);
2325
2326 let writable = &svc.fields[0];
2327 let read_only = &svc.fields[1];
2328
2329 assert!(!svc.is_write_excluded_field(writable, false));
2331 assert!(svc.is_write_excluded_field(read_only, false));
2333 }
2334
2335 #[test]
2339 fn validate_rejects_crud_verb_without_write_ability() {
2340 let svc = ServiceDef::new("order")
2342 .mcp_exposed(true)
2343 .creatable(true)
2344 .field("id", DataType::Integer, FieldMeaning::Identifier);
2345 let err = svc.validate().unwrap_err();
2346 assert!(matches!(err, crate::Error::Validation(_)));
2347 assert!(err.to_string().contains("mcp_write_ability"));
2348
2349 let svc_u = ServiceDef::new("order")
2351 .mcp_exposed(true)
2352 .updatable(true)
2353 .field("id", DataType::Integer, FieldMeaning::Identifier);
2354 assert!(svc_u.validate().is_err());
2355
2356 let svc_d = ServiceDef::new("order")
2358 .mcp_exposed(true)
2359 .deletable(true)
2360 .field("id", DataType::Integer, FieldMeaning::Identifier);
2361 assert!(svc_d.validate().is_err());
2362
2363 let svc_ok = ServiceDef::new("order")
2365 .mcp_exposed(true)
2366 .creatable(true)
2367 .mcp_write_ability("manage-orders")
2368 .field("id", DataType::Integer, FieldMeaning::Identifier);
2369 assert!(svc_ok.validate().is_ok());
2370 }
2371}