Skip to main content

ferro_projections/
service.rs

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