1#[cfg(feature = "projections")]
10use ferro_projections::{
11 ActionDef, Cardinality, DataType, FieldDef, FieldMeaning, Intent, IntentHint, RelationshipDef,
12 ServiceDef, StateMachine,
13};
14
15#[cfg(feature = "projections")]
16use std::path::{Path, PathBuf};
17
18#[cfg(feature = "projections")]
31pub(crate) fn emit_service_def_source(service: &ServiceDef) -> String {
32 let name = &service.name;
33 let snake_name = crate::naming::to_snake_case(name);
36 let fn_name = format!("{snake_name}_service");
37
38 let mut uses_action = false;
41 let mut uses_guard = false;
42 let mut uses_relationship = false;
43 let mut uses_state_machine = false;
44 let mut uses_intent_hint = false;
45 let mut uses_intent = false;
46 let mut uses_cardinality = false;
47
48 if !service.actions.is_empty() {
49 uses_action = true;
50 }
51 if !service.guards.is_empty() {
52 uses_guard = true;
53 }
54 if !service.relationships.is_empty() {
55 uses_relationship = true;
56 uses_cardinality = true;
57 }
58 if service.state_machine.is_some() {
59 uses_state_machine = true;
60 }
61 if !service.intent_hints.is_empty() {
62 uses_intent_hint = true;
63 uses_intent = true;
64 }
65
66 let mut use_items: Vec<&str> = vec!["DataType", "FieldMeaning", "ServiceDef"];
68 if uses_action {
69 use_items.push("ActionDef");
70 }
71 if uses_guard {
72 use_items.push("GuardDef");
73 }
74 if uses_relationship {
75 use_items.push("RelationshipDef");
76 }
77 if uses_cardinality {
78 use_items.push("Cardinality");
79 }
80 if uses_state_machine {
81 use_items.push("StateDef");
82 use_items.push("StateMachine");
83 use_items.push("Transition");
84 }
85 if uses_intent_hint {
86 use_items.push("IntentHint");
87 }
88 if uses_intent {
89 use_items.push("Intent");
90 }
91 use_items.sort_unstable();
92 use_items.dedup();
93
94 let use_line = format!("use ferro::{{{}}};\n", use_items.join(", "));
95
96 let mut chain: Vec<String> = Vec::new();
98 chain.push(format!(" ServiceDef::new({name:?})"));
99
100 if let Some(ref dn) = service.display_name {
101 chain.push(format!(" .display_name({dn:?})"));
102 }
103 if let Some(ref desc) = service.description {
104 chain.push(format!(" .description({desc:?})"));
105 }
106
107 for field in &service.fields {
108 let builder_method = field_builder_method(field);
109 let dt = emit_data_type(&field.data_type);
110 let meaning = emit_field_meaning(&field.meaning);
111 chain.push(format!(
112 " .{builder_method}({:?}, {dt}, {meaning})",
113 field.name
114 ));
115 }
116
117 for guard in &service.guards {
118 chain.push(format!(" .guard(GuardDef::new({:?}))", guard.name));
119 }
120
121 for action in &service.actions {
122 chain.push(emit_action_def(action));
123 }
124
125 for rel in &service.relationships {
126 chain.push(emit_relationship_def(rel));
127 }
128
129 for hint in &service.intent_hints {
130 chain.push(emit_intent_hint(hint));
131 }
132
133 if let Some(ref sm) = service.state_machine {
134 chain.push(emit_state_machine(sm));
135 }
136
137 let builder_body = chain.join("\n");
138
139 format!(
140 "{use_line}\n/// Build the {name} service projection.\npub fn {fn_name}() -> ServiceDef {{\n{builder_body}\n}}\n"
141 )
142}
143
144#[cfg(feature = "projections")]
153fn field_builder_method(field: &FieldDef) -> &'static str {
154 if !field.readable {
155 "write_only_field"
156 } else if !field.writable {
157 "read_only_field"
158 } else if field.is_list {
159 "list_field"
160 } else if !field.required {
161 "optional_field"
162 } else {
163 "field"
164 }
165}
166
167#[cfg(feature = "projections")]
172fn emit_data_type(dt: &DataType) -> &'static str {
173 match dt {
174 DataType::String => "DataType::String",
175 DataType::Integer => "DataType::Integer",
176 DataType::Float => "DataType::Float",
177 DataType::Boolean => "DataType::Boolean",
178 DataType::DateTime => "DataType::DateTime",
179 DataType::Date => "DataType::Date",
180 DataType::Json => "DataType::Json",
181 DataType::Binary => "DataType::Binary",
182 DataType::Uuid => "DataType::Uuid",
183 DataType::Enum => "DataType::Enum",
184 }
185}
186
187#[cfg(feature = "projections")]
193fn emit_field_meaning(m: &FieldMeaning) -> String {
194 match m {
195 FieldMeaning::Identifier => "FieldMeaning::Identifier".into(),
196 FieldMeaning::ForeignKey => "FieldMeaning::ForeignKey".into(),
197 FieldMeaning::EntityName => "FieldMeaning::EntityName".into(),
198 FieldMeaning::Email => "FieldMeaning::Email".into(),
199 FieldMeaning::Phone => "FieldMeaning::Phone".into(),
200 FieldMeaning::Url => "FieldMeaning::Url".into(),
201 FieldMeaning::ImageUrl => "FieldMeaning::ImageUrl".into(),
202 FieldMeaning::Money => "FieldMeaning::Money".into(),
203 FieldMeaning::Percentage => "FieldMeaning::Percentage".into(),
204 FieldMeaning::Quantity => "FieldMeaning::Quantity".into(),
205 FieldMeaning::Status => "FieldMeaning::Status".into(),
206 FieldMeaning::Category => "FieldMeaning::Category".into(),
207 FieldMeaning::Boolean => "FieldMeaning::Boolean".into(),
208 FieldMeaning::FreeText => "FieldMeaning::FreeText".into(),
209 FieldMeaning::CreatedAt => "FieldMeaning::CreatedAt".into(),
210 FieldMeaning::UpdatedAt => "FieldMeaning::UpdatedAt".into(),
211 FieldMeaning::DateTime => "FieldMeaning::DateTime".into(),
212 FieldMeaning::Sensitive => "FieldMeaning::Sensitive".into(),
213 FieldMeaning::Custom(s) => format!(r#"FieldMeaning::Custom({s:?}.into())"#),
214 }
215}
216
217#[cfg(feature = "projections")]
219fn emit_action_def(action: &ActionDef) -> String {
220 let mut parts = vec![format!("ActionDef::new({:?})", action.name)];
221 if let Some(ref dn) = action.display_name {
222 parts.push(format!(".display_name({dn:?})"));
223 }
224 if let Some(ref desc) = action.description {
225 parts.push(format!(".description({desc:?})"));
226 }
227 for pre in &action.preconditions {
228 parts.push(format!(".precondition({pre:?})"));
229 }
230 for eff in &action.effects {
231 parts.push(format!(".effect({eff:?})"));
232 }
233 if let Some(ref trigger) = action.transition_trigger {
234 parts.push(format!(".transition_trigger({trigger:?})"));
235 }
236 format!(" .action({})", parts.join(""))
237}
238
239#[cfg(feature = "projections")]
241fn emit_relationship_def(rel: &RelationshipDef) -> String {
242 let card = emit_cardinality(&rel.cardinality);
243 let mut parts = vec![format!(
244 "RelationshipDef::new({:?}, {:?}, {card})",
245 rel.name, rel.target
246 )];
247 if let Some(ref fk) = rel.foreign_key {
248 parts.push(format!(".foreign_key({fk:?})"));
249 }
250 if let Some(ref inv) = rel.inverse {
251 parts.push(format!(".inverse({inv:?})"));
252 }
253 format!(" .relationship({})", parts.join(""))
254}
255
256#[cfg(feature = "projections")]
260fn emit_cardinality(card: &Cardinality) -> &'static str {
261 match card {
262 Cardinality::OneToOne => "Cardinality::OneToOne",
263 Cardinality::OneToMany => "Cardinality::OneToMany",
264 Cardinality::ManyToOne => "Cardinality::ManyToOne",
265 Cardinality::ManyToMany => "Cardinality::ManyToMany",
266 }
267}
268
269#[cfg(feature = "projections")]
273fn emit_intent_hint(hint: &IntentHint) -> String {
274 match hint {
275 IntentHint::Primary(intent) => {
276 format!(
277 " .intent_hint(IntentHint::Primary({}))",
278 emit_intent(intent)
279 )
280 }
281 IntentHint::Exclude(intent) => {
282 format!(
283 " .intent_hint(IntentHint::Exclude({}))",
284 emit_intent(intent)
285 )
286 }
287 }
288}
289
290#[cfg(feature = "projections")]
295fn emit_intent(intent: &Intent) -> String {
296 match intent {
297 Intent::Browse => "Intent::Browse".into(),
298 Intent::Focus => "Intent::Focus".into(),
299 Intent::Collect => "Intent::Collect".into(),
300 Intent::Process => "Intent::Process".into(),
301 Intent::Summarize => "Intent::Summarize".into(),
302 Intent::Analyze => "Intent::Analyze".into(),
303 Intent::Track => "Intent::Track".into(),
304 Intent::Custom(s) => format!(r#"Intent::Custom({s:?}.into())"#),
305 }
306}
307
308#[cfg(feature = "projections")]
310fn emit_state_machine(sm: &StateMachine) -> String {
311 let mut lines = vec![format!(
312 " .state_machine(StateMachine::new({:?})",
313 sm.name
314 )];
315
316 if let Some(ref dn) = sm.display_name {
317 lines.push(format!(" .display_name({dn:?})"));
318 }
319 if !sm.initial_state.is_empty() {
320 lines.push(format!(" .initial({:?})", sm.initial_state));
321 }
322 for state in &sm.states {
323 let mut s = format!(" .state(StateDef::new({:?})", state.name);
324 if let Some(ref dn) = state.display_name {
325 s.push_str(&format!(".display_name({dn:?})"));
326 }
327 if state.is_final {
328 s.push_str(".final_state()");
329 }
330 s.push(')');
331 lines.push(s);
332 }
333 for t in &sm.transitions {
334 let mut tr = format!(
335 " .transition(Transition::new({:?}, {:?}, {:?})",
336 t.from, t.event, t.to
337 );
338 if let Some(ref g) = t.guard {
339 tr.push_str(&format!(".guard({g:?})"));
340 }
341 tr.push(')');
342 lines.push(tr);
343 }
344 lines.push(" )".to_string());
345 lines.join("\n")
346}
347
348#[cfg(feature = "projections")]
358pub(crate) fn resolve_projection_path(raw: &str) -> Result<PathBuf, String> {
359 let snake = crate::naming::to_snake_case(raw);
360 if !crate::naming::is_valid_identifier(&snake) {
361 return Err(format!(
362 "'{raw}' is not a valid projection name (must be a Rust identifier after snake_case conversion)"
363 ));
364 }
365 Ok(Path::new("src/projections").join(format!("{snake}.rs")))
366}
367
368#[cfg(feature = "projections")]
373pub(crate) enum OutputResult {
374 DryRun(String),
376 Written(PathBuf),
378 AlreadyExists(PathBuf),
380}
381
382#[cfg(feature = "projections")]
386pub(crate) fn render_output(
387 service: &ServiceDef,
388 dry_run: bool,
389 out_dir: &Path,
390) -> Result<OutputResult, String> {
391 if dry_run {
392 let json = serde_json::to_string_pretty(service)
393 .map_err(|e| format!("Failed to serialize ServiceDef: {e}"))?;
394 return Ok(OutputResult::DryRun(json));
395 }
396
397 let rel = resolve_projection_path(&service.name)?;
399 let projection_file = out_dir.join(&rel);
400
401 if projection_file.exists() {
402 return Ok(OutputResult::AlreadyExists(projection_file));
403 }
404
405 let projections_dir = projection_file
406 .parent()
407 .ok_or_else(|| "cannot determine projections directory".to_string())?;
408
409 std::fs::create_dir_all(projections_dir)
410 .map_err(|e| format!("Failed to create projections directory: {e}"))?;
411
412 let content = emit_service_def_source(service);
413 std::fs::write(&projection_file, &content)
414 .map_err(|e| format!("Failed to write projection file: {e}"))?;
415
416 let file_stem = projection_file
418 .file_stem()
419 .and_then(|s| s.to_str())
420 .unwrap_or(&service.name);
421 let mod_file = projections_dir.join("mod.rs");
422
423 if mod_file.exists() {
424 let mod_content = std::fs::read_to_string(&mod_file).unwrap_or_default();
425 let pub_mod_decl = format!("pub mod {file_stem};");
426 if !mod_content.contains(&pub_mod_decl) {
427 crate::commands::make_projection::update_mod_file(&mod_file, file_stem)
428 .map_err(|e| format!("Failed to update mod.rs: {e}"))?;
429 }
430 } else {
431 std::fs::write(&mod_file, format!("pub mod {file_stem};\n"))
432 .map_err(|e| format!("Failed to create mod.rs: {e}"))?;
433 }
434
435 Ok(OutputResult::Written(projection_file))
436}
437
438#[cfg(feature = "projections")]
450pub fn run(description: String, dry_run: bool) {
451 use console::style;
452
453 let rt = match tokio::runtime::Runtime::new() {
455 Ok(r) => r,
456 Err(e) => {
457 eprintln!(
458 "{} Failed to create tokio runtime: {e}",
459 style("Error:").red().bold()
460 );
461 std::process::exit(1);
462 }
463 };
464
465 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
466
467 println!("{} Generating ServiceDef via AI...", style("⏳").cyan());
468
469 let service = match rt.block_on(ferro_mcp::tools::ai_scaffold::scaffold_core(
470 &description,
471 &cwd,
472 )) {
473 Ok(s) => s,
474 Err(e) => {
475 eprintln!("{} {e}", style("Error:").red().bold());
476 std::process::exit(1);
477 }
478 };
479
480 match render_output(&service, dry_run, &cwd) {
481 Ok(OutputResult::DryRun(json)) => {
482 println!("{json}");
483 }
484 Ok(OutputResult::Written(path)) => {
485 println!("{} Created {}", style("✓").green(), path.display());
486 }
487 Ok(OutputResult::AlreadyExists(path)) => {
488 eprintln!(
489 "{} Projection already exists at {}. Delete it first or use a different name.",
490 style("Info:").yellow().bold(),
491 path.display()
492 );
493 std::process::exit(0);
494 }
495 Err(e) => {
496 eprintln!("{} {e}", style("Error:").red().bold());
497 std::process::exit(1);
498 }
499 }
500}
501
502#[cfg(all(test, feature = "projections"))]
507mod tests {
508 use super::*;
509 use ferro_projections::{
510 ActionDef, Cardinality, DataType, FieldMeaning, GuardDef, Intent, IntentHint,
511 RelationshipDef, ServiceDef, StateDef, StateMachine, Transition,
512 };
513 use tempfile::TempDir;
514
515 #[test]
518 fn emit_data_type_datetime_is_not_snake_case() {
519 assert_eq!(emit_data_type(&DataType::DateTime), "DataType::DateTime");
520 assert_ne!(emit_data_type(&DataType::DateTime), "DataType::date_time");
522 }
523
524 #[test]
525 fn emit_field_meaning_known_variant() {
526 assert_eq!(
527 emit_field_meaning(&FieldMeaning::Money),
528 "FieldMeaning::Money"
529 );
530 }
531
532 #[test]
533 fn emit_field_meaning_custom_variant() {
534 assert_eq!(
535 emit_field_meaning(&FieldMeaning::Custom("sku".into())),
536 r#"FieldMeaning::Custom("sku".into())"#
537 );
538 }
539
540 #[test]
541 fn emit_field_meaning_description_escaping() {
542 let m = FieldMeaning::Custom(r#"has "quotes""#.into());
544 let emitted = emit_field_meaning(&m);
545 assert!(emitted.contains(r#"\"quotes\""#), "got: {emitted}");
547 }
548
549 #[test]
550 fn emitter_round_trip() {
551 let service = ServiceDef::new("test_service")
553 .display_name("Test Service")
554 .description("A test service with \"quoted\" description")
555 .field("id", DataType::Integer, FieldMeaning::Identifier)
556 .optional_field("note", DataType::String, FieldMeaning::FreeText)
557 .field("sku", DataType::String, FieldMeaning::Custom("sku".into()))
558 .guard(GuardDef::new("authenticated"))
559 .action(ActionDef::new("create"))
560 .relationship(RelationshipDef::new(
561 "customer",
562 "customer",
563 Cardinality::ManyToOne,
564 ))
565 .intent_hint(IntentHint::Primary(Intent::Browse))
566 .state_machine(
567 StateMachine::new("lifecycle")
568 .initial("active")
569 .state(StateDef::new("active"))
570 .state(StateDef::new("closed").final_state())
571 .transition(Transition::new("active", "close", "closed")),
572 );
573
574 let source = emit_service_def_source(&service);
575
576 assert!(
578 source.contains("pub fn test_service_service() -> ServiceDef"),
579 "missing function signature\nsource:\n{source}"
580 );
581 assert!(
583 source.contains(r#"ServiceDef::new("test_service")"#),
584 "source:\n{source}"
585 );
586 assert!(source.contains("DataType::Integer"), "source:\n{source}");
588 assert!(
590 source.contains("FieldMeaning::Identifier"),
591 "source:\n{source}"
592 );
593 assert!(
595 source.contains(r#"FieldMeaning::Custom("sku".into())"#),
596 "source:\n{source}"
597 );
598 assert!(source.contains("GuardDef::new("), "source:\n{source}");
600 assert!(source.contains("ActionDef::new("), "source:\n{source}");
602 assert!(
604 source.contains("RelationshipDef::new("),
605 "source:\n{source}"
606 );
607 assert!(source.contains("IntentHint"), "source:\n{source}");
609 assert!(source.contains("StateMachine"), "source:\n{source}");
611 }
612
613 #[test]
614 fn emitter_pascal_case_name_produces_snake_case_function() {
615 let service =
618 ServiceDef::new("OrderItem").field("id", DataType::Integer, FieldMeaning::Identifier);
619 let source = emit_service_def_source(&service);
620 assert!(
622 source.contains("pub fn order_item_service() -> ServiceDef"),
623 "function name must be snake_case\nsource:\n{source}"
624 );
625 assert!(
626 !source.contains("pub fn OrderItem_service"),
627 "function name must NOT be PascalCase\nsource:\n{source}"
628 );
629 assert!(
631 source.contains(r#"ServiceDef::new("OrderItem")"#),
632 "ServiceDef::new must use the original name\nsource:\n{source}"
633 );
634 }
635
636 #[test]
639 fn ai_make_rejects_path_traversal() {
640 assert!(
641 resolve_projection_path("../../etc/passwd").is_err(),
642 "path traversal should be rejected"
643 );
644 }
645
646 #[test]
647 fn ai_make_accepts_valid_name() {
648 let path = resolve_projection_path("Order").expect("valid name should succeed");
649 assert!(path.ends_with("src/projections/order.rs"), "got: {path:?}");
650 }
651
652 #[test]
655 fn dry_run_no_file_write() {
656 let dir = TempDir::new().expect("tempdir");
657 let service =
658 ServiceDef::new("preview").field("id", DataType::Integer, FieldMeaning::Identifier);
659
660 let result = render_output(&service, true, dir.path()).expect("dry-run should not error");
661
662 match result {
663 OutputResult::DryRun(json) => {
664 assert!(json.contains("preview"), "JSON should contain service name");
665 let proj_file = dir.path().join("src/projections/preview.rs");
667 assert!(!proj_file.exists(), "dry-run must not write files");
668 }
669 _ => panic!("expected DryRun result"),
670 }
671 }
672}