Skip to main content

ferro_cli/commands/
projection_check.rs

1//! CLI command: `ferro projection:check`
2//!
3//! Scans `src/projections/` for ServiceDef functions, reconstructs them
4//! via regex-based source parsing, and validates structural correctness.
5
6use 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
16/// Result of validating a single projection.
17struct CheckResult {
18    fn_name: String,
19    file: String,
20    warnings: Vec<String>,
21    errors: Vec<String>,
22}
23
24/// Execute the projection:check command.
25pub 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    // Filter by name if provided
44    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    // Print results
71    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
125/// Discover all projection functions in src/projections/.
126fn 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
165/// Check a single projection: read source, reconstruct ServiceDef, validate.
166fn 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    // Extract service name and display name
181    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
218/// Reconstruct a ServiceDef from source code using regex parsing.
219///
220/// Replicates the reconstruction logic from ferro-mcp's render_projection tool.
221fn 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    // Parse description
233    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    // Parse fields
239    service = parse_and_add_fields(service, content);
240
241    // Parse relationships
242    service = parse_and_add_relationships(service, content);
243
244    // Parse actions
245    service = parse_and_add_actions(service, content);
246
247    // Parse state machine
248    if content.contains(".state_machine(") {
249        if let Some(sm) = parse_state_machine(content) {
250            service = service.state_machine(sm);
251        }
252    }
253
254    // Parse intent hints
255    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        // Create a projection with an orphan state (unreachable + dead end)
519        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        // mod.rs should be skipped
558        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}