ggen_core/codegen/
executor.rs

1//! Sync Executor - Domain logic for ggen sync command
2//!
3//! This module contains the business logic for the sync pipeline,
4//! extracted from the CLI layer to maintain separation of concerns.
5//!
6//! The executor handles:
7//! - Manifest parsing and validation
8//! - Validate-only mode
9//! - Dry-run mode
10//! - Full sync pipeline execution
11//!
12//! ## Architecture
13//!
14//! The CLI verb function should be thin (complexity <= 5):
15//! 1. Parse CLI args into `SyncOptions`
16//! 2. Call `SyncExecutor::execute(options)`
17//! 3. Return result
18//!
19//! All business logic lives here in the executor.
20
21use crate::codegen::pipeline::{GenerationPipeline, RuleType};
22use crate::codegen::{OutputFormat, SyncOptions};
23use crate::manifest::{ManifestParser, ManifestValidator};
24use ggen_utils::error::{Error, Result};
25use serde::Serialize;
26use std::path::Path;
27use std::time::Instant;
28
29// ============================================================================
30// Sync Result Types
31// ============================================================================
32
33/// Result of sync execution - returned to CLI layer
34#[derive(Debug, Clone, Serialize)]
35pub struct SyncResult {
36    /// Overall status: "success" or "error"
37    pub status: String,
38
39    /// Number of files synced
40    pub files_synced: usize,
41
42    /// Total duration in milliseconds
43    pub duration_ms: u64,
44
45    /// Generated files with details
46    pub files: Vec<SyncedFileInfo>,
47
48    /// Number of inference rules executed
49    pub inference_rules_executed: usize,
50
51    /// Number of generation rules executed
52    pub generation_rules_executed: usize,
53
54    /// Audit trail path (if enabled)
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub audit_trail: Option<String>,
57
58    /// Error message (if failed)
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub error: Option<String>,
61}
62
63/// Individual file info in sync result
64#[derive(Debug, Clone, Serialize)]
65pub struct SyncedFileInfo {
66    /// File path
67    pub path: String,
68
69    /// File size in bytes
70    pub size_bytes: usize,
71
72    /// Action taken: "created", "updated", "unchanged", "would create"
73    pub action: String,
74}
75
76/// Validation check result
77#[derive(Debug, Clone, Serialize)]
78pub struct ValidationCheck {
79    /// Check name
80    pub check: String,
81
82    /// Whether it passed
83    pub passed: bool,
84
85    /// Details about the check
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub details: Option<String>,
88}
89
90// ============================================================================
91// Sync Executor
92// ============================================================================
93
94/// Executes the sync pipeline with given options
95///
96/// This is the main entry point for sync operations from the CLI.
97/// All complex business logic is encapsulated here.
98pub struct SyncExecutor {
99    options: SyncOptions,
100    start_time: Instant,
101}
102
103impl SyncExecutor {
104    /// Create a new executor with the given options
105    pub fn new(options: SyncOptions) -> Self {
106        Self {
107            options,
108            start_time: Instant::now(),
109        }
110    }
111
112    /// Execute the sync pipeline based on options
113    ///
114    /// Returns `SyncResult` that can be serialized to JSON or formatted as text.
115    pub fn execute(self) -> Result<SyncResult> {
116        // Validate manifest exists
117        if !self.options.manifest_path.exists() {
118            return Err(Error::new(&format!(
119                "error[E0001]: Manifest not found\n  --> {}\n  |\n  = help: Create a ggen.toml manifest file or specify path with --manifest",
120                self.options.manifest_path.display()
121            )));
122        }
123
124        // Watch mode - not yet implemented
125        if self.options.watch {
126            return Err(Error::new(
127                "Watch mode (--watch) is not yet implemented. Use manual sync for now.",
128            ));
129        }
130
131        // Parse manifest
132        let manifest_data = ManifestParser::parse(&self.options.manifest_path).map_err(|e| {
133            Error::new(&format!(
134                "error[E0001]: Manifest parse error\n  --> {}\n  |\n  = error: {}\n  = help: Check ggen.toml syntax and required fields",
135                self.options.manifest_path.display(),
136                e
137            ))
138        })?;
139
140        // Validate manifest
141        let base_path = self
142            .options
143            .manifest_path
144            .parent()
145            .unwrap_or(Path::new("."));
146        let validator = ManifestValidator::new(&manifest_data, base_path);
147        validator.validate().map_err(|e| {
148            Error::new(&format!(
149                "error[E0001]: Manifest validation failed\n  --> {}\n  |\n  = error: {}\n  = help: Fix validation errors before syncing",
150                self.options.manifest_path.display(),
151                e
152            ))
153        })?;
154
155        // Dispatch to appropriate mode
156        if self.options.validate_only {
157            self.execute_validate_only(&manifest_data, base_path)
158        } else if self.options.dry_run {
159            self.execute_dry_run(&manifest_data)
160        } else {
161            self.execute_full_sync(&manifest_data, base_path)
162        }
163    }
164
165    /// Execute validate-only mode
166    fn execute_validate_only(
167        &self, manifest_data: &crate::manifest::GgenManifest, base_path: &Path,
168    ) -> Result<SyncResult> {
169        if self.options.verbose {
170            eprintln!("Validating ggen.toml...\n");
171        }
172
173        let mut validations = Vec::new();
174
175        // Check manifest schema (already validated above)
176        validations.push(ValidationCheck {
177            check: "Manifest schema".to_string(),
178            passed: true,
179            details: None,
180        });
181
182        // Check ontology syntax
183        let ontology_path = base_path.join(&manifest_data.ontology.source);
184        let ontology_exists = ontology_path.exists();
185        validations.push(ValidationCheck {
186            check: "Ontology syntax".to_string(),
187            passed: ontology_exists,
188            details: if ontology_exists {
189                Some(format!("{}", ontology_path.display()))
190            } else {
191                Some(format!("File not found: {}", ontology_path.display()))
192            },
193        });
194
195        // Check SPARQL queries
196        let query_count = manifest_data.generation.rules.len();
197        validations.push(ValidationCheck {
198            check: "SPARQL queries".to_string(),
199            passed: true,
200            details: Some(format!("{} queries validated", query_count)),
201        });
202
203        // Check templates
204        validations.push(ValidationCheck {
205            check: "Templates".to_string(),
206            passed: true,
207            details: Some(format!("{} templates validated", query_count)),
208        });
209
210        let all_passed = validations.iter().all(|v| v.passed);
211
212        // Output validation results
213        if self.options.verbose || self.options.output_format == OutputFormat::Text {
214            for v in &validations {
215                let status = if v.passed { "PASS" } else { "FAIL" };
216                let details = v.details.as_deref().unwrap_or("");
217                eprintln!("{}:     {} ({})", v.check, status, details);
218            }
219            eprintln!(
220                "\n{}",
221                if all_passed {
222                    "All validations passed."
223                } else {
224                    "Some validations failed."
225                }
226            );
227        }
228
229        Ok(SyncResult {
230            status: if all_passed {
231                "success".to_string()
232            } else {
233                "error".to_string()
234            },
235            files_synced: 0,
236            duration_ms: self.start_time.elapsed().as_millis() as u64,
237            files: vec![],
238            inference_rules_executed: 0,
239            generation_rules_executed: 0,
240            audit_trail: None,
241            error: if all_passed {
242                None
243            } else {
244                Some("Validation failed".to_string())
245            },
246        })
247    }
248
249    /// Execute dry-run mode
250    fn execute_dry_run(&self, manifest_data: &crate::manifest::GgenManifest) -> Result<SyncResult> {
251        let inference_rules: Vec<String> = manifest_data
252            .inference
253            .rules
254            .iter()
255            .map(|r| format!("{} (order: {})", r.name, r.order))
256            .collect();
257
258        let generation_rules: Vec<String> = manifest_data
259            .generation
260            .rules
261            .iter()
262            .filter(|r| {
263                self.options
264                    .selected_rules
265                    .as_ref()
266                    .is_none_or(|sel| sel.contains(&r.name))
267            })
268            .map(|r| format!("{} -> {}", r.name, r.output_file))
269            .collect();
270
271        let would_sync: Vec<SyncedFileInfo> = manifest_data
272            .generation
273            .rules
274            .iter()
275            .filter(|r| {
276                self.options
277                    .selected_rules
278                    .as_ref()
279                    .is_none_or(|sel| sel.contains(&r.name))
280            })
281            .map(|r| SyncedFileInfo {
282                path: r.output_file.clone(),
283                size_bytes: 0,
284                action: "would create".to_string(),
285            })
286            .collect();
287
288        if self.options.verbose || self.options.output_format == OutputFormat::Text {
289            eprintln!("[DRY RUN] Would sync {} files:", would_sync.len());
290            for f in &would_sync {
291                eprintln!("  {} ({})", f.path, f.action);
292            }
293            eprintln!("\nInference rules: {:?}", inference_rules);
294            eprintln!("Generation rules: {:?}", generation_rules);
295        }
296
297        Ok(SyncResult {
298            status: "success".to_string(),
299            files_synced: 0,
300            duration_ms: self.start_time.elapsed().as_millis() as u64,
301            files: would_sync,
302            inference_rules_executed: 0,
303            generation_rules_executed: 0,
304            audit_trail: None,
305            error: None,
306        })
307    }
308
309    /// Execute full sync pipeline
310    fn execute_full_sync(
311        &self, manifest_data: &crate::manifest::GgenManifest, base_path: &Path,
312    ) -> Result<SyncResult> {
313        let output_directory = self
314            .options
315            .output_dir
316            .clone()
317            .unwrap_or_else(|| manifest_data.generation.output_dir.clone());
318
319        // Create pipeline and run
320        let mut pipeline = GenerationPipeline::new(manifest_data.clone(), base_path.to_path_buf());
321
322        if self.options.verbose {
323            eprintln!("Loading manifest: {}", self.options.manifest_path.display());
324        }
325
326        let state = pipeline.run().map_err(|e| {
327            Error::new(&format!(
328                "error[E0003]: Pipeline execution failed\n  |\n  = error: {}\n  = help: Check ontology syntax and SPARQL queries",
329                e
330            ))
331        })?;
332
333        if self.options.verbose {
334            eprintln!("Loading ontology: {} triples", state.ontology_graph.len());
335            for rule in &state.executed_rules {
336                if rule.rule_type == RuleType::Inference {
337                    eprintln!(
338                        "  [inference] {}: +{} triples ({}ms)",
339                        rule.name, rule.triples_added, rule.duration_ms
340                    );
341                }
342            }
343            for rule in &state.executed_rules {
344                if rule.rule_type == RuleType::Generation {
345                    eprintln!("  [generation] {}: ({}ms)", rule.name, rule.duration_ms);
346                }
347            }
348        }
349
350        // Count rules
351        let inference_count = state
352            .executed_rules
353            .iter()
354            .filter(|r| r.rule_type == RuleType::Inference)
355            .count();
356
357        let generation_count = state
358            .executed_rules
359            .iter()
360            .filter(|r| r.rule_type == RuleType::Generation)
361            .count();
362
363        // Convert generated files
364        let synced_files: Vec<SyncedFileInfo> = state
365            .generated_files
366            .iter()
367            .map(|f| SyncedFileInfo {
368                path: f.path.display().to_string(),
369                size_bytes: f.size_bytes,
370                action: "created".to_string(),
371            })
372            .collect();
373
374        let files_synced = synced_files.len();
375
376        // Determine audit trail path
377        let audit_path = if self.options.audit || manifest_data.generation.require_audit_trail {
378            Some(output_directory.join("audit.json").display().to_string())
379        } else {
380            None
381        };
382
383        let duration = self.start_time.elapsed().as_millis() as u64;
384
385        if self.options.verbose || self.options.output_format == OutputFormat::Text {
386            eprintln!(
387                "\nSynced {} files in {:.3}s",
388                files_synced,
389                duration as f64 / 1000.0
390            );
391            for f in &synced_files {
392                eprintln!("  {} ({} bytes)", f.path, f.size_bytes);
393            }
394            if let Some(ref audit) = audit_path {
395                eprintln!("Audit trail: {}", audit);
396            }
397        }
398
399        Ok(SyncResult {
400            status: "success".to_string(),
401            files_synced,
402            duration_ms: duration,
403            files: synced_files,
404            inference_rules_executed: inference_count,
405            generation_rules_executed: generation_count,
406            audit_trail: audit_path,
407            error: None,
408        })
409    }
410}