1use ferro_projections::{
7 ActionDef, Cardinality, DataType, FieldMeaning, IntentHint, ServiceDef, StateDef, StateMachine,
8 Transition,
9};
10use regex::Regex;
11use std::collections::HashSet;
12use std::fs;
13use std::path::Path;
14use std::process;
15
16struct CheckResult {
18 fn_name: String,
19 file: String,
20 warnings: Vec<String>,
21 errors: Vec<String>,
22}
23
24pub fn execute(name: Option<&str>) {
26 let project_root = std::env::current_dir().unwrap_or_else(|_| {
27 eprintln!("Error: could not determine current directory");
28 process::exit(1);
29 });
30
31 let projections_dir = project_root.join("src/projections");
32 if !projections_dir.exists() {
33 println!("No projections directory found at src/projections/");
34 return;
35 }
36
37 let discovered = discover_projections(&project_root);
38 if discovered.is_empty() {
39 println!("No projections found in src/projections/");
40 return;
41 }
42
43 let targets: Vec<_> = if let Some(filter) = name {
45 discovered
46 .into_iter()
47 .filter(|(fn_name, _)| fn_name == filter)
48 .collect()
49 } else {
50 discovered
51 };
52
53 if targets.is_empty() {
54 if let Some(filter) = name {
55 eprintln!("Projection '{filter}' not found in src/projections/");
56 process::exit(1);
57 }
58 println!("No projections found in src/projections/");
59 return;
60 }
61
62 println!("Checking projections...");
63
64 let mut results = Vec::new();
65 for (fn_name, file) in &targets {
66 let result = check_projection(&project_root, fn_name, file);
67 results.push(result);
68 }
69
70 let mut total_warnings = 0usize;
72 let mut total_errors = 0usize;
73 let mut projections_with_issues = 0usize;
74
75 for result in &results {
76 let issue_count = result.warnings.len() + result.errors.len();
77 if issue_count == 0 {
78 println!(
79 " \u{2713} {} ({}) \u{2014} 0 warnings",
80 result.fn_name, result.file
81 );
82 } else {
83 projections_with_issues += 1;
84 if !result.errors.is_empty() {
85 println!(
86 " \u{2717} {} ({}) \u{2014} {} error(s), {} warning(s)",
87 result.fn_name,
88 result.file,
89 result.errors.len(),
90 result.warnings.len()
91 );
92 } else {
93 println!(
94 " \u{26a0} {} ({}) \u{2014} {} warning(s)",
95 result.fn_name,
96 result.file,
97 result.warnings.len()
98 );
99 }
100 for err in &result.errors {
101 println!(" - Error: {err}");
102 }
103 for warn in &result.warnings {
104 println!(" - {warn}");
105 }
106 }
107 total_warnings += result.warnings.len();
108 total_errors += result.errors.len();
109 }
110
111 println!();
112 println!(
113 "{} projection(s) checked, {} warning(s), {} error(s) in {} projection(s)",
114 results.len(),
115 total_warnings,
116 total_errors,
117 projections_with_issues
118 );
119
120 if total_errors > 0 {
121 process::exit(1);
122 }
123}
124
125fn discover_projections(project_root: &Path) -> Vec<(String, String)> {
127 let projections_dir = project_root.join("src/projections");
128 let fn_re = Regex::new(r"pub\s+fn\s+(\w+)\s*\(.*\).*->\s*ServiceDef").unwrap();
129
130 let mut result = Vec::new();
131
132 let entries: Vec<_> = match fs::read_dir(&projections_dir) {
133 Ok(entries) => entries.filter_map(|e| e.ok()).collect(),
134 Err(_) => return result,
135 };
136
137 for entry in entries {
138 let path = entry.path();
139 if path.extension().is_none_or(|ext| ext != "rs") {
140 continue;
141 }
142 if path.file_name().is_some_and(|n| n == "mod.rs") {
143 continue;
144 }
145
146 let content = match fs::read_to_string(&path) {
147 Ok(c) => c,
148 Err(_) => continue,
149 };
150
151 let relative = path
152 .strip_prefix(project_root)
153 .unwrap_or(&path)
154 .to_string_lossy()
155 .to_string();
156
157 for cap in fn_re.captures_iter(&content) {
158 result.push((cap[1].to_string(), relative.clone()));
159 }
160 }
161
162 result
163}
164
165fn check_projection(project_root: &Path, fn_name: &str, file: &str) -> CheckResult {
167 let file_path = project_root.join(file);
168 let content = match fs::read_to_string(&file_path) {
169 Ok(c) => c,
170 Err(e) => {
171 return CheckResult {
172 fn_name: fn_name.to_string(),
173 file: file.to_string(),
174 warnings: Vec::new(),
175 errors: vec![format!("could not read file: {}", e)],
176 }
177 }
178 };
179
180 let service_name_re = Regex::new(r#"ServiceDef::new\("([^"]+)"\)"#).unwrap();
182 let display_name_re = Regex::new(r#"\.display_name\("([^"]+)"\)"#).unwrap();
183
184 let service_name = service_name_re
185 .captures(&content)
186 .map(|c| c[1].to_string())
187 .unwrap_or_else(|| fn_name.to_string());
188 let display_name = display_name_re.captures(&content).map(|c| c[1].to_string());
189
190 let service = match reconstruct_service_def(&service_name, &display_name, &content) {
191 Ok(s) => s,
192 Err(e) => {
193 return CheckResult {
194 fn_name: fn_name.to_string(),
195 file: file.to_string(),
196 warnings: Vec::new(),
197 errors: vec![format!("reconstruction failed: {}", e)],
198 }
199 }
200 };
201
202 match service.validate() {
203 Ok(warnings) => CheckResult {
204 fn_name: fn_name.to_string(),
205 file: file.to_string(),
206 warnings: warnings.iter().map(|w| format!("{w:?}")).collect(),
207 errors: Vec::new(),
208 },
209 Err(e) => CheckResult {
210 fn_name: fn_name.to_string(),
211 file: file.to_string(),
212 warnings: Vec::new(),
213 errors: vec![e.to_string()],
214 },
215 }
216}
217
218fn reconstruct_service_def(
222 service_name: &str,
223 display_name: &Option<String>,
224 content: &str,
225) -> Result<ServiceDef, String> {
226 let mut service = ServiceDef::new(service_name);
227
228 if let Some(dn) = display_name {
229 service = service.display_name(dn.clone());
230 }
231
232 let desc_re = Regex::new(r#"\.description\("([^"]+)"\)"#).unwrap();
234 if let Some(cap) = desc_re.captures(content) {
235 service = service.description(cap[1].to_string());
236 }
237
238 service = parse_and_add_fields(service, content);
240
241 service = parse_and_add_relationships(service, content);
243
244 service = parse_and_add_actions(service, content);
246
247 if content.contains(".state_machine(") {
249 if let Some(sm) = parse_state_machine(content) {
250 service = service.state_machine(sm);
251 }
252 }
253
254 service = parse_and_add_intent_hints(service, content);
256
257 Ok(service)
258}
259
260fn parse_and_add_fields(mut service: ServiceDef, content: &str) -> ServiceDef {
261 let field_re =
262 Regex::new(r#"\.field\("([^"]+)",\s*DataType::(\w+),\s*FieldMeaning::(\w+)\)"#).unwrap();
263 for cap in field_re.captures_iter(content) {
264 if let (Some(dt), Some(fm)) = (parse_data_type(&cap[2]), parse_field_meaning(&cap[3])) {
265 service = service.field(&cap[1], dt, fm);
266 }
267 }
268
269 let opt_re =
270 Regex::new(r#"\.optional_field\("([^"]+)",\s*DataType::(\w+),\s*FieldMeaning::(\w+)\)"#)
271 .unwrap();
272 for cap in opt_re.captures_iter(content) {
273 if let (Some(dt), Some(fm)) = (parse_data_type(&cap[2]), parse_field_meaning(&cap[3])) {
274 service = service.optional_field(&cap[1], dt, fm);
275 }
276 }
277
278 let ro_re =
279 Regex::new(r#"\.read_only_field\("([^"]+)",\s*DataType::(\w+),\s*FieldMeaning::(\w+)\)"#)
280 .unwrap();
281 for cap in ro_re.captures_iter(content) {
282 if let (Some(dt), Some(fm)) = (parse_data_type(&cap[2]), parse_field_meaning(&cap[3])) {
283 service = service.read_only_field(&cap[1], dt, fm);
284 }
285 }
286
287 let wo_re =
288 Regex::new(r#"\.write_only_field\("([^"]+)",\s*DataType::(\w+),\s*FieldMeaning::(\w+)\)"#)
289 .unwrap();
290 for cap in wo_re.captures_iter(content) {
291 if let (Some(dt), Some(fm)) = (parse_data_type(&cap[2]), parse_field_meaning(&cap[3])) {
292 service = service.write_only_field(&cap[1], dt, fm);
293 }
294 }
295
296 service
297}
298
299fn parse_and_add_relationships(mut service: ServiceDef, content: &str) -> ServiceDef {
300 let hm_re = Regex::new(r#"\.has_many\("([^"]+)",\s*"([^"]+)"\)"#).unwrap();
301 for cap in hm_re.captures_iter(content) {
302 service = service.has_many(&cap[1], &cap[2]);
303 }
304
305 let bt_re = Regex::new(r#"\.belongs_to\("([^"]+)",\s*"([^"]+)"\)"#).unwrap();
306 for cap in bt_re.captures_iter(content) {
307 service = service.belongs_to(&cap[1], &cap[2]);
308 }
309
310 let ho_re = Regex::new(r#"\.has_one\("([^"]+)",\s*"([^"]+)"\)"#).unwrap();
311 for cap in ho_re.captures_iter(content) {
312 service = service.has_one(&cap[1], &cap[2]);
313 }
314
315 let btm_re = Regex::new(r#"\.belongs_to_many\("([^"]+)",\s*"([^"]+)"\)"#).unwrap();
316 for cap in btm_re.captures_iter(content) {
317 service = service.belongs_to_many(&cap[1], &cap[2]);
318 }
319
320 let rel_re = Regex::new(
321 r#"\.relationship\(RelationshipDef::new\("([^"]+)",\s*"([^"]+)",\s*Cardinality::(\w+)\)"#,
322 )
323 .unwrap();
324 for cap in rel_re.captures_iter(content) {
325 if let Some(card) = parse_cardinality(&cap[3]) {
326 use ferro_projections::RelationshipDef;
327 service = service.relationship(RelationshipDef::new(&cap[1], &cap[2], card));
328 }
329 }
330
331 service
332}
333
334fn parse_and_add_actions(mut service: ServiceDef, content: &str) -> ServiceDef {
335 let action_re = Regex::new(r#"\.action\(ActionDef::new\("([^"]+)"\)"#).unwrap();
336 for cap in action_re.captures_iter(content) {
337 let action = ActionDef::new(&cap[1]);
338 service = service.action(action);
339 }
340 service
341}
342
343fn parse_and_add_intent_hints(mut service: ServiceDef, content: &str) -> ServiceDef {
344 let re = Regex::new(r#"\.intent_hint\(IntentHint::(\w+)\(Intent::(\w+)\)\)"#).unwrap();
345 for cap in re.captures_iter(content) {
346 let intent = match parse_intent(&cap[2]) {
347 Some(i) => i,
348 None => continue,
349 };
350 let hint = match &cap[1] {
351 "Primary" => IntentHint::Primary(intent),
352 "Exclude" => IntentHint::Exclude(intent),
353 _ => continue,
354 };
355 service = service.intent_hint(hint);
356 }
357 service
358}
359
360fn parse_state_machine(content: &str) -> Option<StateMachine> {
361 let name_re = Regex::new(r#"StateMachine::new\("([^"]+)"\)"#).unwrap();
362 let name = name_re.captures(content).map(|c| c[1].to_string())?;
363
364 let initial_re = Regex::new(r#"\.initial\("([^"]+)"\)"#).unwrap();
365 let initial = initial_re
366 .captures(content)
367 .map(|c| c[1].to_string())
368 .unwrap_or_else(|| "initial".to_string());
369
370 let mut machine = StateMachine::new(&name).initial(&initial);
371
372 let final_state_re = Regex::new(r#"StateDef::new\("([^"]+)"\)[^;]*\.final_state\(\)"#).unwrap();
373 let final_states: HashSet<String> = final_state_re
374 .captures_iter(content)
375 .map(|c| c[1].to_string())
376 .collect();
377
378 let state_re = Regex::new(r#"StateDef::new\("([^"]+)"\)"#).unwrap();
379 for cap in state_re.captures_iter(content) {
380 let state_name = cap[1].to_string();
381 let mut state = StateDef::new(&state_name);
382 if final_states.contains(&state_name) {
383 state = state.final_state();
384 }
385 machine = machine.state(state);
386 }
387
388 let trans_re = Regex::new(r#"Transition::new\("([^"]+)",\s*"([^"]+)",\s*"([^"]+)"\)"#).unwrap();
389 for cap in trans_re.captures_iter(content) {
390 machine = machine.transition(Transition::new(&cap[1], &cap[2], &cap[3]));
391 }
392
393 Some(machine)
394}
395
396fn parse_data_type(s: &str) -> Option<DataType> {
397 match s {
398 "String" => Some(DataType::String),
399 "Integer" => Some(DataType::Integer),
400 "Float" => Some(DataType::Float),
401 "Boolean" => Some(DataType::Boolean),
402 "DateTime" => Some(DataType::DateTime),
403 "Date" => Some(DataType::Date),
404 "Json" => Some(DataType::Json),
405 "Binary" => Some(DataType::Binary),
406 "Uuid" => Some(DataType::Uuid),
407 "Enum" => Some(DataType::Enum),
408 _ => None,
409 }
410}
411
412fn parse_field_meaning(s: &str) -> Option<FieldMeaning> {
413 match s {
414 "Identifier" => Some(FieldMeaning::Identifier),
415 "ForeignKey" => Some(FieldMeaning::ForeignKey),
416 "EntityName" => Some(FieldMeaning::EntityName),
417 "Email" => Some(FieldMeaning::Email),
418 "Phone" => Some(FieldMeaning::Phone),
419 "Url" => Some(FieldMeaning::Url),
420 "ImageUrl" => Some(FieldMeaning::ImageUrl),
421 "Money" => Some(FieldMeaning::Money),
422 "Percentage" => Some(FieldMeaning::Percentage),
423 "Quantity" => Some(FieldMeaning::Quantity),
424 "Status" => Some(FieldMeaning::Status),
425 "Category" => Some(FieldMeaning::Category),
426 "Boolean" => Some(FieldMeaning::Boolean),
427 "FreeText" => Some(FieldMeaning::FreeText),
428 "CreatedAt" => Some(FieldMeaning::CreatedAt),
429 "UpdatedAt" => Some(FieldMeaning::UpdatedAt),
430 "DateTime" => Some(FieldMeaning::DateTime),
431 "Sensitive" => Some(FieldMeaning::Sensitive),
432 other => Some(FieldMeaning::Custom(other.to_string())),
433 }
434}
435
436fn parse_cardinality(s: &str) -> Option<Cardinality> {
437 match s {
438 "OneToOne" => Some(Cardinality::OneToOne),
439 "OneToMany" => Some(Cardinality::OneToMany),
440 "ManyToOne" => Some(Cardinality::ManyToOne),
441 "ManyToMany" => Some(Cardinality::ManyToMany),
442 _ => None,
443 }
444}
445
446fn parse_intent(s: &str) -> Option<ferro_projections::Intent> {
447 use ferro_projections::Intent;
448 match s {
449 "Browse" => Some(Intent::Browse),
450 "Focus" => Some(Intent::Focus),
451 "Collect" => Some(Intent::Collect),
452 "Process" => Some(Intent::Process),
453 "Summarize" => Some(Intent::Summarize),
454 "Analyze" => Some(Intent::Analyze),
455 "Track" => Some(Intent::Track),
456 other => Some(Intent::Custom(other.to_string())),
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn test_discover_empty_project() {
466 let tmp = std::path::PathBuf::from("/tmp/ferro_projection_check_test_empty");
467 let result = discover_projections(&tmp);
468 assert!(result.is_empty());
469 }
470
471 #[test]
472 fn test_reconstruct_minimal() {
473 let content = r#"
474pub fn user_service() -> ServiceDef {
475 ServiceDef::new("user")
476 .display_name("User")
477 .field("id", DataType::Integer, FieldMeaning::Identifier)
478 .field("name", DataType::String, FieldMeaning::EntityName)
479}
480 "#;
481
482 let service = reconstruct_service_def("user", &Some("User".to_string()), content);
483 assert!(service.is_ok());
484 let svc = service.unwrap();
485 assert_eq!(svc.name, "user");
486 assert_eq!(svc.fields.len(), 2);
487 }
488
489 #[test]
490 fn test_check_valid_projection() {
491 let tmp = tempfile::tempdir().unwrap();
492 let proj_dir = tmp.path().join("src/projections");
493 fs::create_dir_all(&proj_dir).unwrap();
494
495 let content = r#"
496use ferro::{ServiceDef, DataType, FieldMeaning};
497
498pub fn order_service() -> ServiceDef {
499 ServiceDef::new("order")
500 .display_name("Order")
501 .field("id", DataType::Integer, FieldMeaning::Identifier)
502 .field("total", DataType::Float, FieldMeaning::Money)
503}
504 "#;
505 fs::write(proj_dir.join("order.rs"), content).unwrap();
506
507 let result = check_projection(tmp.path(), "order_service", "src/projections/order.rs");
508 assert!(result.errors.is_empty());
509 assert!(result.warnings.is_empty());
510 }
511
512 #[test]
513 fn test_check_projection_with_orphan_state() {
514 let tmp = tempfile::tempdir().unwrap();
515 let proj_dir = tmp.path().join("src/projections");
516 fs::create_dir_all(&proj_dir).unwrap();
517
518 let content = r#"
520use ferro::{ServiceDef, DataType, FieldMeaning, StateMachine, StateDef, Transition};
521
522pub fn broken_service() -> ServiceDef {
523 ServiceDef::new("broken")
524 .field("id", DataType::Integer, FieldMeaning::Identifier)
525 .state_machine(
526 StateMachine::new("lifecycle")
527 .initial("draft")
528 .state(StateDef::new("draft"))
529 .state(StateDef::new("published").final_state())
530 .state(StateDef::new("orphan"))
531 .transition(Transition::new("draft", "publish", "published"))
532 )
533}
534 "#;
535 fs::write(proj_dir.join("broken.rs"), content).unwrap();
536
537 let result = check_projection(tmp.path(), "broken_service", "src/projections/broken.rs");
538 assert!(result.errors.is_empty());
539 assert!(
540 !result.warnings.is_empty(),
541 "Should have warnings for orphan state"
542 );
543 }
544
545 #[test]
546 fn test_discover_projections() {
547 let tmp = tempfile::tempdir().unwrap();
548 let proj_dir = tmp.path().join("src/projections");
549 fs::create_dir_all(&proj_dir).unwrap();
550
551 fs::write(
552 proj_dir.join("user.rs"),
553 r#"pub fn user_service() -> ServiceDef { ServiceDef::new("user") }"#,
554 )
555 .unwrap();
556
557 fs::write(proj_dir.join("mod.rs"), "pub mod user;").unwrap();
559
560 let discovered = discover_projections(tmp.path());
561 assert_eq!(discovered.len(), 1);
562 assert_eq!(discovered[0].0, "user_service");
563 }
564}