1use 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#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum OutputLayout {
55 OneEntityPerFile,
57 OneAspectPerFile,
59 Flat,
61 NestedByNamespace,
63 Custom,
65}
66
67pub struct MultiFileOptions {
69 pub output_dir: PathBuf,
71 pub layout: OutputLayout,
73 pub generate_index: bool,
75 pub language: String,
77 pub generate_docs: bool,
79 pub custom_naming: Option<fn(&str) -> String>,
81 pub ts_options: Option<TsOptions>,
83 pub java_options: Option<JavaOptions>,
85 pub python_options: Option<PythonOptions>,
87 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#[derive(Debug, Clone)]
144pub struct GeneratedFile {
145 pub path: PathBuf,
147 pub content: String,
149 pub file_type: String,
151}
152
153pub struct MultiFileGenerator {
155 options: MultiFileOptions,
156}
157
158impl MultiFileGenerator {
159 pub fn new(options: MultiFileOptions) -> Self {
161 Self { options }
162 }
163
164 pub fn generate_typescript(&self, aspect: &Aspect) -> Result<Vec<GeneratedFile>> {
166 let mut files = Vec::new();
167
168 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 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 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 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 pub fn generate_python(&self, aspect: &Aspect) -> Result<Vec<GeneratedFile>> {
216 let mut files = Vec::new();
217
218 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 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 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 pub fn generate_java(&self, aspect: &Aspect) -> Result<Vec<GeneratedFile>> {
258 let mut files = Vec::new();
259
260 let java_content = generate_java(
262 aspect,
263 self.options.java_options.clone().unwrap_or_default(),
264 )?;
265
266 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 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 pub fn generate_scala(&self, aspect: &Aspect) -> Result<Vec<GeneratedFile>> {
293 let mut files = Vec::new();
294
295 let scala_content = generate_scala(
297 aspect,
298 self.options.scala_options.clone().unwrap_or_default(),
299 )?;
300
301 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 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 pub fn write_files(&self, files: &[GeneratedFile]) -> Result<()> {
328 use std::fs;
329
330 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 if let Some(parent) = full_path.parent() {
338 fs::create_dir_all(parent)?;
339 }
340
341 fs::write(&full_path, &file.content)?;
343 }
344
345 Ok(())
346 }
347
348 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 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 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 if let Some(ref data_type) = characteristic.data_type {
379 }
382 }
383 }
384
385 entities
387 }
388
389 fn generate_typescript_entity(&self, entity: &Entity) -> Result<String> {
390 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 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 let aspect_module = self.to_snake_case(&aspect.name());
429 code.push_str(&format!("export * from './{}';\n", aspect_module));
430
431 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 let aspect_module = self.to_snake_case(&aspect.name());
446 code.push_str(&format!("from .{} import *\n", aspect_module));
447
448 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 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 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 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 "string".to_string()
542 }
543
544 fn python_type_from_property(&self, _property: &crate::metamodel::Property) -> String {
545 "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}