Skip to main content

oxirs_samm/generators/
multifile.rs

1//! Multi-file code generation support
2//!
3//! This module enables generation of code organized across multiple files,
4//! which is essential for real-world projects. Instead of generating a single
5//! monolithic file, code is organized following language-specific conventions:
6//!
7//! - **TypeScript**: One file per entity + barrel index.ts
8//! - **Java**: One class per file + package structure
9//! - **Python**: One module per entity + __init__.py
10//! - **Rust**: One file per struct + mod.rs
11//! - **Scala**: One file per case class + package object
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use oxirs_samm::generators::multifile::{MultiFileGenerator, MultiFileOptions, OutputLayout};
17//! use oxirs_samm::parser::parse_aspect_model;
18//! use std::path::Path;
19//!
20//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21//! let aspect = parse_aspect_model("Movement.ttl").await?;
22//!
23//! let options = MultiFileOptions {
24//!     output_dir: Path::new("./generated").to_path_buf(),
25//!     layout: OutputLayout::OneEntityPerFile,
26//!     generate_index: true,
27//!     language: "typescript".to_string(),
28//!     ..Default::default()
29//! };
30//!
31//! let generator = MultiFileGenerator::new(options);
32//! let files = generator.generate_typescript(&aspect)?;
33//!
34//! // Files now contains:
35//! // - movement.ts (main aspect)
36//! // - position.ts (Position entity)
37//! // - velocity.ts (Velocity entity)
38//! // - index.ts (barrel export)
39//! # Ok(())
40//! # }
41//! ```
42
43use crate::error::{Result, SammError};
44use crate::generators::{
45    generate_graphql, generate_java, generate_python, generate_scala, generate_sql,
46    generate_typescript, JavaOptions, PythonOptions, ScalaOptions, SqlDialect, TsOptions,
47};
48use crate::metamodel::{Aspect, Entity, ModelElement};
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51
52/// Output layout strategy for multi-file generation
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum OutputLayout {
55    /// One file per entity (recommended for most languages)
56    OneEntityPerFile,
57    /// One file per aspect (groups related entities)
58    OneAspectPerFile,
59    /// Flat layout (all in output_dir)
60    Flat,
61    /// Nested by namespace (follows URN structure)
62    NestedByNamespace,
63    /// Custom layout with user-provided path function
64    Custom,
65}
66
67/// Options for multi-file code generation
68pub struct MultiFileOptions {
69    /// Base output directory
70    pub output_dir: PathBuf,
71    /// Layout strategy
72    pub layout: OutputLayout,
73    /// Generate index/barrel files (index.ts, __init__.py, mod.rs)
74    pub generate_index: bool,
75    /// Target language (typescript, java, python, rust, scala)
76    pub language: String,
77    /// Include documentation files (README.md)
78    pub generate_docs: bool,
79    /// Custom file naming function (entity_name -> filename)
80    pub custom_naming: Option<fn(&str) -> String>,
81    /// Language-specific options (TypeScript)
82    pub ts_options: Option<TsOptions>,
83    /// Language-specific options (Java)
84    pub java_options: Option<JavaOptions>,
85    /// Language-specific options (Python)
86    pub python_options: Option<PythonOptions>,
87    /// Language-specific options (Scala)
88    pub scala_options: Option<ScalaOptions>,
89}
90
91impl std::fmt::Debug for MultiFileOptions {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("MultiFileOptions")
94            .field("output_dir", &self.output_dir)
95            .field("layout", &self.layout)
96            .field("generate_index", &self.generate_index)
97            .field("language", &self.language)
98            .field("generate_docs", &self.generate_docs)
99            .field("custom_naming", &self.custom_naming.is_some())
100            .field("ts_options", &self.ts_options)
101            .field("java_options", &self.java_options)
102            .field("python_options", &self.python_options)
103            .field("scala_options", &self.scala_options)
104            .finish()
105    }
106}
107
108impl Clone for MultiFileOptions {
109    fn clone(&self) -> Self {
110        Self {
111            output_dir: self.output_dir.clone(),
112            layout: self.layout.clone(),
113            generate_index: self.generate_index,
114            language: self.language.clone(),
115            generate_docs: self.generate_docs,
116            custom_naming: self.custom_naming,
117            ts_options: self.ts_options.clone(),
118            java_options: self.java_options.clone(),
119            python_options: self.python_options.clone(),
120            scala_options: self.scala_options.clone(),
121        }
122    }
123}
124
125impl Default for MultiFileOptions {
126    fn default() -> Self {
127        Self {
128            output_dir: PathBuf::from("./generated"),
129            layout: OutputLayout::OneEntityPerFile,
130            generate_index: true,
131            language: "typescript".to_string(),
132            generate_docs: false,
133            custom_naming: None,
134            ts_options: None,
135            java_options: None,
136            python_options: None,
137            scala_options: None,
138        }
139    }
140}
141
142/// Represents a generated file with path and content
143#[derive(Debug, Clone)]
144pub struct GeneratedFile {
145    /// Relative path from output_dir
146    pub path: PathBuf,
147    /// File content
148    pub content: String,
149    /// File type (e.g., "typescript", "python", "java")
150    pub file_type: String,
151}
152
153/// Multi-file code generator
154pub struct MultiFileGenerator {
155    options: MultiFileOptions,
156}
157
158impl MultiFileGenerator {
159    /// Create a new multi-file generator with options
160    pub fn new(options: MultiFileOptions) -> Self {
161        Self { options }
162    }
163
164    /// Generate TypeScript code across multiple files
165    pub fn generate_typescript(&self, aspect: &Aspect) -> Result<Vec<GeneratedFile>> {
166        let mut files = Vec::new();
167
168        // Generate main aspect file
169        let aspect_content =
170            generate_typescript(aspect, self.options.ts_options.clone().unwrap_or_default())?;
171        let aspect_filename = self.get_filename(&aspect.name(), "ts");
172        files.push(GeneratedFile {
173            path: self.resolve_path(&aspect_filename),
174            content: aspect_content.clone(),
175            file_type: "typescript".to_string(),
176        });
177
178        // Generate entity files (if OneEntityPerFile layout)
179        if self.options.layout == OutputLayout::OneEntityPerFile {
180            for entity in self.extract_entities(aspect) {
181                let entity_content = self.generate_typescript_entity(&entity)?;
182                let entity_filename = self.get_filename(&entity.name(), "ts");
183                files.push(GeneratedFile {
184                    path: self.resolve_path(&entity_filename),
185                    content: entity_content,
186                    file_type: "typescript".to_string(),
187                });
188            }
189        }
190
191        // Generate index.ts barrel file
192        if self.options.generate_index {
193            let index_content = self.generate_typescript_index(aspect)?;
194            files.push(GeneratedFile {
195                path: self.resolve_path("index.ts"),
196                content: index_content,
197                file_type: "typescript".to_string(),
198            });
199        }
200
201        // Generate README.md
202        if self.options.generate_docs {
203            let readme_content = self.generate_readme(aspect)?;
204            files.push(GeneratedFile {
205                path: self.resolve_path("README.md"),
206                content: readme_content,
207                file_type: "markdown".to_string(),
208            });
209        }
210
211        Ok(files)
212    }
213
214    /// Generate Python code across multiple files
215    pub fn generate_python(&self, aspect: &Aspect) -> Result<Vec<GeneratedFile>> {
216        let mut files = Vec::new();
217
218        // Generate main aspect module
219        let aspect_content = generate_python(
220            aspect,
221            self.options.python_options.clone().unwrap_or_default(),
222        )?;
223        let aspect_filename = self.get_filename(&aspect.name(), "py");
224        files.push(GeneratedFile {
225            path: self.resolve_path(&aspect_filename),
226            content: aspect_content,
227            file_type: "python".to_string(),
228        });
229
230        // Generate entity modules (if OneEntityPerFile layout)
231        if self.options.layout == OutputLayout::OneEntityPerFile {
232            for entity in self.extract_entities(aspect) {
233                let entity_content = self.generate_python_entity(&entity)?;
234                let entity_filename = self.get_filename(&entity.name(), "py");
235                files.push(GeneratedFile {
236                    path: self.resolve_path(&entity_filename),
237                    content: entity_content,
238                    file_type: "python".to_string(),
239                });
240            }
241        }
242
243        // Generate __init__.py
244        if self.options.generate_index {
245            let init_content = self.generate_python_init(aspect)?;
246            files.push(GeneratedFile {
247                path: self.resolve_path("__init__.py"),
248                content: init_content,
249                file_type: "python".to_string(),
250            });
251        }
252
253        Ok(files)
254    }
255
256    /// Generate Java code across multiple files
257    pub fn generate_java(&self, aspect: &Aspect) -> Result<Vec<GeneratedFile>> {
258        let mut files = Vec::new();
259
260        // Java always generates one file per class
261        let java_content = generate_java(
262            aspect,
263            self.options.java_options.clone().unwrap_or_default(),
264        )?;
265
266        // Split by class definitions
267        let classes = self.split_java_classes(&java_content)?;
268
269        for (class_name, class_content) in classes {
270            let filename = format!("{}.java", class_name);
271            files.push(GeneratedFile {
272                path: self.resolve_path(&filename),
273                content: class_content,
274                file_type: "java".to_string(),
275            });
276        }
277
278        // Generate package-info.java
279        if self.options.generate_index {
280            let package_info = self.generate_java_package_info(aspect)?;
281            files.push(GeneratedFile {
282                path: self.resolve_path("package-info.java"),
283                content: package_info,
284                file_type: "java".to_string(),
285            });
286        }
287
288        Ok(files)
289    }
290
291    /// Generate Scala code across multiple files
292    pub fn generate_scala(&self, aspect: &Aspect) -> Result<Vec<GeneratedFile>> {
293        let mut files = Vec::new();
294
295        // Scala: one file per case class
296        let scala_content = generate_scala(
297            aspect,
298            self.options.scala_options.clone().unwrap_or_default(),
299        )?;
300
301        // Split by case class definitions
302        let classes = self.split_scala_classes(&scala_content)?;
303
304        for (class_name, class_content) in classes {
305            let filename = format!("{}.scala", class_name);
306            files.push(GeneratedFile {
307                path: self.resolve_path(&filename),
308                content: class_content,
309                file_type: "scala".to_string(),
310            });
311        }
312
313        // Generate package object
314        if self.options.generate_index {
315            let package_obj = self.generate_scala_package_object(aspect)?;
316            files.push(GeneratedFile {
317                path: self.resolve_path("package.scala"),
318                content: package_obj,
319                file_type: "scala".to_string(),
320            });
321        }
322
323        Ok(files)
324    }
325
326    /// Write all generated files to disk
327    pub fn write_files(&self, files: &[GeneratedFile]) -> Result<()> {
328        use std::fs;
329
330        // Create output directory
331        fs::create_dir_all(&self.options.output_dir)?;
332
333        for file in files {
334            let full_path = self.options.output_dir.join(&file.path);
335
336            // Create parent directories
337            if let Some(parent) = full_path.parent() {
338                fs::create_dir_all(parent)?;
339            }
340
341            // Write file
342            fs::write(&full_path, &file.content)?;
343        }
344
345        Ok(())
346    }
347
348    // Private helper methods
349
350    fn get_filename(&self, name: &str, extension: &str) -> String {
351        if let Some(ref naming_fn) = self.options.custom_naming {
352            format!("{}.{}", naming_fn(name), extension)
353        } else {
354            // Convert to snake_case for filenames
355            let snake_name = self.to_snake_case(name);
356            format!("{}.{}", snake_name, extension)
357        }
358    }
359
360    fn resolve_path(&self, filename: &str) -> PathBuf {
361        match self.options.layout {
362            OutputLayout::Flat => PathBuf::from(filename),
363            OutputLayout::NestedByNamespace => {
364                // Extract namespace from filename and create nested structure
365                // For now, simple flat layout
366                PathBuf::from(filename)
367            }
368            _ => PathBuf::from(filename),
369        }
370    }
371
372    fn extract_entities(&self, aspect: &Aspect) -> Vec<Entity> {
373        let entities = Vec::new();
374
375        for property in &aspect.properties {
376            if let Some(ref characteristic) = property.characteristic {
377                // Check if characteristic references an entity
378                if let Some(ref data_type) = characteristic.data_type {
379                    // In real implementation, resolve URN to entity
380                    // For now, we'll create a placeholder
381                }
382            }
383        }
384
385        // Return entities from aspect (in real implementation, resolve from URNs)
386        entities
387    }
388
389    fn generate_typescript_entity(&self, entity: &Entity) -> Result<String> {
390        // Generate TypeScript interface for a single entity
391        let mut code = String::new();
392        code.push_str(&format!("// Generated entity: {}\n\n", entity.name()));
393        code.push_str(&format!("export interface {} {{\n", entity.name()));
394
395        for property in &entity.properties {
396            let prop_name = property.name();
397            let prop_type = self.ts_type_from_property(property);
398            code.push_str(&format!("  {}: {};\n", prop_name, prop_type));
399        }
400
401        code.push_str("}\n");
402        Ok(code)
403    }
404
405    fn generate_python_entity(&self, entity: &Entity) -> Result<String> {
406        // Generate Python dataclass for a single entity
407        let mut code = String::new();
408        code.push_str("# Generated entity\n");
409        code.push_str("from dataclasses import dataclass\n");
410        code.push_str("from typing import Optional\n\n");
411        code.push_str("@dataclass\n");
412        code.push_str(&format!("class {}:\n", entity.name()));
413
414        for property in &entity.properties {
415            let prop_name = self.to_snake_case(&property.name());
416            let prop_type = self.python_type_from_property(property);
417            code.push_str(&format!("    {}: {}\n", prop_name, prop_type));
418        }
419
420        Ok(code)
421    }
422
423    fn generate_typescript_index(&self, aspect: &Aspect) -> Result<String> {
424        let mut code = String::new();
425        code.push_str("// Barrel export for all generated types\n\n");
426
427        // Export main aspect
428        let aspect_module = self.to_snake_case(&aspect.name());
429        code.push_str(&format!("export * from './{}';\n", aspect_module));
430
431        // Export entities
432        for entity in self.extract_entities(aspect) {
433            let entity_module = self.to_snake_case(&entity.name());
434            code.push_str(&format!("export * from './{}';\n", entity_module));
435        }
436
437        Ok(code)
438    }
439
440    fn generate_python_init(&self, aspect: &Aspect) -> Result<String> {
441        let mut code = String::new();
442        code.push_str("# Python package initialization\n\n");
443
444        // Import main aspect
445        let aspect_module = self.to_snake_case(&aspect.name());
446        code.push_str(&format!("from .{} import *\n", aspect_module));
447
448        // Import entities
449        for entity in self.extract_entities(aspect) {
450            let entity_module = self.to_snake_case(&entity.name());
451            code.push_str(&format!("from .{} import *\n", entity_module));
452        }
453
454        code.push_str("\n__all__ = [\n");
455        code.push_str(&format!("    '{}',\n", aspect.name()));
456        for entity in self.extract_entities(aspect) {
457            code.push_str(&format!("    '{}',\n", entity.name()));
458        }
459        code.push_str("]\n");
460
461        Ok(code)
462    }
463
464    fn generate_java_package_info(&self, aspect: &Aspect) -> Result<String> {
465        let mut code = String::new();
466        code.push_str("/**\n");
467        code.push_str(&format!(
468            " * Generated package for {} aspect\n",
469            aspect.name()
470        ));
471        if let Some(desc) = aspect.metadata.get_description("en") {
472            code.push_str(&format!(" * {}\n", desc));
473        }
474        code.push_str(" */\n");
475        code.push_str("package com.example.generated;\n");
476        Ok(code)
477    }
478
479    fn generate_scala_package_object(&self, aspect: &Aspect) -> Result<String> {
480        let mut code = String::new();
481        code.push_str(&format!(
482            "package object {} {{\n",
483            self.to_snake_case(&aspect.name())
484        ));
485        code.push_str("  // Package-level utilities\n");
486        code.push_str("}\n");
487        Ok(code)
488    }
489
490    fn generate_readme(&self, aspect: &Aspect) -> Result<String> {
491        let mut md = String::new();
492        md.push_str(&format!("# {} - Generated Code\n\n", aspect.name()));
493        md.push_str("This code was automatically generated from a SAMM aspect model.\n\n");
494        md.push_str("## Overview\n\n");
495        if let Some(desc) = aspect.metadata.get_description("en") {
496            md.push_str(&format!("{}\n\n", desc));
497        }
498        md.push_str("## Files\n\n");
499        md.push_str("- Main aspect implementation\n");
500        md.push_str("- Entity definitions\n");
501        md.push_str("- Index/barrel exports\n\n");
502        md.push_str("## Usage\n\n");
503        md.push_str("See individual files for usage examples.\n");
504        Ok(md)
505    }
506
507    fn split_java_classes(&self, content: &str) -> Result<HashMap<String, String>> {
508        let mut classes = HashMap::new();
509
510        // Simple regex-based splitting (in production, use proper Java parser)
511        let class_pattern =
512            regex::Regex::new(r"public\s+class\s+(\w+)").expect("construction should succeed");
513
514        for class_match in class_pattern.captures_iter(content) {
515            let class_name = class_match.get(1).expect("key should exist").as_str();
516            // For now, include the entire content for each class
517            // In real implementation, extract individual class definition
518            classes.insert(class_name.to_string(), content.to_string());
519        }
520
521        Ok(classes)
522    }
523
524    fn split_scala_classes(&self, content: &str) -> Result<HashMap<String, String>> {
525        let mut classes = HashMap::new();
526
527        // Simple regex-based splitting
528        let class_pattern =
529            regex::Regex::new(r"case\s+class\s+(\w+)").expect("construction should succeed");
530
531        for class_match in class_pattern.captures_iter(content) {
532            let class_name = class_match.get(1).expect("key should exist").as_str();
533            classes.insert(class_name.to_string(), content.to_string());
534        }
535
536        Ok(classes)
537    }
538
539    fn ts_type_from_property(&self, _property: &crate::metamodel::Property) -> String {
540        // Simplified type mapping
541        "string".to_string()
542    }
543
544    fn python_type_from_property(&self, _property: &crate::metamodel::Property) -> String {
545        // Simplified type mapping
546        "str".to_string()
547    }
548
549    fn to_snake_case(&self, s: &str) -> String {
550        use crate::utils::naming::to_snake_case;
551        to_snake_case(s)
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use crate::metamodel::{Aspect, Property};
559
560    #[test]
561    fn test_multifile_options_default() {
562        let options = MultiFileOptions::default();
563        assert_eq!(options.layout, OutputLayout::OneEntityPerFile);
564        assert!(options.generate_index);
565        assert_eq!(options.language, "typescript");
566    }
567
568    #[test]
569    fn test_output_layout_variants() {
570        assert_eq!(OutputLayout::Flat, OutputLayout::Flat);
571        assert_ne!(OutputLayout::Flat, OutputLayout::OneEntityPerFile);
572    }
573
574    #[test]
575    fn test_generated_file_creation() {
576        let file = GeneratedFile {
577            path: PathBuf::from("test.ts"),
578            content: "export interface Test {}".to_string(),
579            file_type: "typescript".to_string(),
580        };
581
582        assert_eq!(file.path, PathBuf::from("test.ts"));
583        assert!(file.content.contains("Test"));
584    }
585
586    #[test]
587    fn test_get_filename() {
588        let options = MultiFileOptions::default();
589        let generator = MultiFileGenerator::new(options);
590
591        let filename = generator.get_filename("MyEntity", "ts");
592        assert_eq!(filename, "my_entity.ts");
593    }
594
595    #[test]
596    fn test_typescript_index_generation() {
597        let aspect = Aspect::new("urn:samm:test:1.0.0#TestAspect".to_string());
598
599        let options = MultiFileOptions::default();
600        let generator = MultiFileGenerator::new(options);
601        let index = generator
602            .generate_typescript_index(&aspect)
603            .expect("generation should succeed");
604
605        assert!(index.contains("export"));
606        assert!(index.contains("test_aspect"));
607    }
608
609    #[test]
610    fn test_python_init_generation() {
611        let aspect = Aspect::new("urn:samm:test:1.0.0#TestAspect".to_string());
612
613        let options = MultiFileOptions::default();
614        let generator = MultiFileGenerator::new(options);
615        let init = generator
616            .generate_python_init(&aspect)
617            .expect("generation should succeed");
618
619        assert!(init.contains("__all__"));
620        assert!(init.contains("from"));
621    }
622
623    #[test]
624    fn test_readme_generation() {
625        let mut aspect = Aspect::new("urn:samm:test:1.0.0#TestAspect".to_string());
626        aspect
627            .metadata
628            .add_description("en".to_string(), "Test description".to_string());
629
630        let options = MultiFileOptions {
631            generate_docs: true,
632            ..Default::default()
633        };
634        let generator = MultiFileGenerator::new(options);
635        let readme = generator
636            .generate_readme(&aspect)
637            .expect("generation should succeed");
638
639        assert!(readme.contains("# TestAspect"));
640        assert!(readme.contains("Test description"));
641        assert!(readme.contains("## Usage"));
642    }
643}