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