data_modelling_core/model/
saver.rs

1//! Model saving functionality
2//!
3//! Saves models to storage backends, handling YAML serialization.
4//!
5//! File structure:
6//! - Base directory (workspace_path)
7//!   - Domain directories (e.g., `domain1/`, `domain2/`)
8//!     - `domain.yaml` - Domain definition
9//!     - `{name}.odcs.yaml` - ODCS table files
10//!     - `{name}.odps.yaml` - ODPS product files
11//!     - `{name}.cads.yaml` - CADS asset files
12//!   - `tables/` - Legacy: tables not in any domain (backward compatibility)
13
14use crate::export::{
15    cads::CADSExporter, decision::DecisionExporter, knowledge::KnowledgeExporter,
16    markdown::MarkdownExporter, odcs::ODCSExporter, odps::ODPSExporter,
17};
18#[cfg(feature = "bpmn")]
19use crate::models::bpmn::BPMNModel;
20use crate::models::decision::{Decision, DecisionIndex};
21#[cfg(feature = "dmn")]
22use crate::models::dmn::DMNModel;
23use crate::models::knowledge::{KnowledgeArticle, KnowledgeIndex};
24#[cfg(feature = "openapi")]
25use crate::models::openapi::{OpenAPIFormat, OpenAPIModel};
26use crate::models::{cads::CADSAsset, domain::Domain, odps::ODPSDataProduct, table::Table};
27use crate::storage::{StorageBackend, StorageError};
28use anyhow::Result;
29use serde_yaml;
30use std::collections::HashMap;
31use tracing::info;
32use uuid::Uuid;
33
34/// Model saver that uses a storage backend
35pub struct ModelSaver<B: StorageBackend> {
36    storage: B,
37}
38
39impl<B: StorageBackend> ModelSaver<B> {
40    /// Create a new model saver with the given storage backend
41    pub fn new(storage: B) -> Self {
42        Self { storage }
43    }
44
45    /// Save a table to storage
46    ///
47    /// Saves the table as a YAML file in the workspace's `tables/` directory.
48    /// The filename will be based on the table name if yaml_file_path is not provided.
49    pub async fn save_table(
50        &self,
51        workspace_path: &str,
52        table: &TableData,
53    ) -> Result<(), StorageError> {
54        let tables_dir = format!("{}/tables", workspace_path);
55
56        // Ensure tables directory exists
57        if !self.storage.dir_exists(&tables_dir).await? {
58            self.storage.create_dir(&tables_dir).await?;
59        }
60
61        // Determine file path
62        let file_path = if let Some(ref yaml_path) = table.yaml_file_path {
63            format!(
64                "{}/{}",
65                workspace_path,
66                yaml_path.strip_prefix('/').unwrap_or(yaml_path)
67            )
68        } else {
69            // Generate filename from table name
70            let sanitized_name = sanitize_filename(&table.name);
71            format!("{}/tables/{}.yaml", workspace_path, sanitized_name)
72        };
73
74        // Serialize table to YAML
75        let yaml_content = serde_yaml::to_string(&table.yaml_value).map_err(|e| {
76            StorageError::SerializationError(format!("Failed to serialize table: {}", e))
77        })?;
78
79        // Write to storage
80        self.storage
81            .write_file(&file_path, yaml_content.as_bytes())
82            .await?;
83
84        info!("Saved table '{}' to {}", table.name, file_path);
85        Ok(())
86    }
87
88    /// Save relationships to storage
89    ///
90    /// Saves relationships to `relationships.yaml` in the workspace directory.
91    /// Note: Relationships are now stored within domain.yaml files, but this method
92    /// is kept for backward compatibility.
93    pub async fn save_relationships(
94        &self,
95        workspace_path: &str,
96        relationships: &[RelationshipData],
97    ) -> Result<(), StorageError> {
98        let file_path = format!("{}/relationships.yaml", workspace_path);
99
100        // Serialize relationships to YAML
101        let mut yaml_map = serde_yaml::Mapping::new();
102        let mut rels_array = serde_yaml::Sequence::new();
103        for rel in relationships {
104            rels_array.push(rel.yaml_value.clone());
105        }
106        yaml_map.insert(
107            serde_yaml::Value::String("relationships".to_string()),
108            serde_yaml::Value::Sequence(rels_array),
109        );
110        let yaml_value = serde_yaml::Value::Mapping(yaml_map);
111
112        let yaml_content = serde_yaml::to_string(&yaml_value).map_err(|e| {
113            StorageError::SerializationError(format!("Failed to write YAML: {}", e))
114        })?;
115
116        // Write to storage
117        self.storage
118            .write_file(&file_path, yaml_content.as_bytes())
119            .await?;
120
121        info!(
122            "Saved {} relationships to {}",
123            relationships.len(),
124            file_path
125        );
126        Ok(())
127    }
128
129    /// Save a domain to storage
130    ///
131    /// Saves the domain as `domain.yaml` in a domain directory named after the domain.
132    /// Also saves all associated ODCS tables, ODPS products, and CADS assets within the domain directory.
133    pub async fn save_domain(
134        &self,
135        workspace_path: &str,
136        domain: &Domain,
137        tables: &HashMap<Uuid, Table>,
138        odps_products: &HashMap<Uuid, ODPSDataProduct>,
139        cads_assets: &HashMap<Uuid, CADSAsset>,
140    ) -> Result<(), StorageError> {
141        let sanitized_domain_name = sanitize_filename(&domain.name);
142        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
143
144        // Ensure domain directory exists
145        if !self.storage.dir_exists(&domain_dir).await? {
146            self.storage.create_dir(&domain_dir).await?;
147        }
148
149        // Save domain.yaml
150        let domain_yaml = domain.to_yaml().map_err(|e| {
151            StorageError::SerializationError(format!("Failed to serialize domain: {}", e))
152        })?;
153        let domain_file_path = format!("{}/domain.yaml", domain_dir);
154        self.storage
155            .write_file(&domain_file_path, domain_yaml.as_bytes())
156            .await?;
157        info!("Saved domain '{}' to {}", domain.name, domain_file_path);
158
159        // Save ODCS tables referenced by ODCSNodes
160        for odcs_node in &domain.odcs_nodes {
161            if let Some(table_id) = odcs_node.table_id
162                && let Some(table) = tables.get(&table_id)
163            {
164                let sanitized_table_name = sanitize_filename(&table.name);
165                let table_file_path = format!("{}/{}.odcs.yaml", domain_dir, sanitized_table_name);
166                let odcs_yaml = ODCSExporter::export_table(table, "odcs_v3_1_0");
167                self.storage
168                    .write_file(&table_file_path, odcs_yaml.as_bytes())
169                    .await?;
170                info!("Saved ODCS table '{}' to {}", table.name, table_file_path);
171            }
172        }
173
174        // Save ODPS products (if we have a way to identify which products belong to this domain)
175        // For now, we'll save all products that have a matching domain field
176        for product in odps_products.values() {
177            if let Some(product_domain) = &product.domain
178                && product_domain == &domain.name
179            {
180                let sanitized_product_name =
181                    sanitize_filename(product.name.as_ref().unwrap_or(&product.id));
182                let product_file_path =
183                    format!("{}/{}.odps.yaml", domain_dir, sanitized_product_name);
184                let odps_yaml = ODPSExporter::export_product(product);
185                self.storage
186                    .write_file(&product_file_path, odps_yaml.as_bytes())
187                    .await?;
188                info!(
189                    "Saved ODPS product '{}' to {}",
190                    product.id, product_file_path
191                );
192            }
193        }
194
195        // Save CADS assets referenced by CADSNodes
196        for cads_node in &domain.cads_nodes {
197            if let Some(cads_asset_id) = cads_node.cads_asset_id
198                && let Some(asset) = cads_assets.get(&cads_asset_id)
199            {
200                let sanitized_asset_name = sanitize_filename(&asset.name);
201                let asset_file_path = format!("{}/{}.cads.yaml", domain_dir, sanitized_asset_name);
202                let cads_yaml = CADSExporter::export_asset(asset);
203                self.storage
204                    .write_file(&asset_file_path, cads_yaml.as_bytes())
205                    .await?;
206                info!("Saved CADS asset '{}' to {}", asset.name, asset_file_path);
207            }
208        }
209
210        Ok(())
211    }
212
213    /// Save an ODPS product to a domain directory
214    ///
215    /// Saves the product as `{product_name}.odps.yaml` in the specified domain directory.
216    pub async fn save_odps_product(
217        &self,
218        workspace_path: &str,
219        domain_name: &str,
220        product: &ODPSDataProduct,
221    ) -> Result<(), StorageError> {
222        let sanitized_domain_name = sanitize_filename(domain_name);
223        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
224
225        // Ensure domain directory exists
226        if !self.storage.dir_exists(&domain_dir).await? {
227            self.storage.create_dir(&domain_dir).await?;
228        }
229
230        let sanitized_product_name =
231            sanitize_filename(product.name.as_ref().unwrap_or(&product.id));
232        let product_file_path = format!("{}/{}.odps.yaml", domain_dir, sanitized_product_name);
233        let odps_yaml = ODPSExporter::export_product(product);
234        self.storage
235            .write_file(&product_file_path, odps_yaml.as_bytes())
236            .await?;
237
238        info!(
239            "Saved ODPS product '{}' to {}",
240            product.id, product_file_path
241        );
242        Ok(())
243    }
244
245    /// Save a CADS asset to a domain directory
246    ///
247    /// Saves the asset as `{asset_name}.cads.yaml` in the specified domain directory.
248    pub async fn save_cads_asset(
249        &self,
250        workspace_path: &str,
251        domain_name: &str,
252        asset: &CADSAsset,
253    ) -> Result<(), StorageError> {
254        let sanitized_domain_name = sanitize_filename(domain_name);
255        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
256
257        // Ensure domain directory exists
258        if !self.storage.dir_exists(&domain_dir).await? {
259            self.storage.create_dir(&domain_dir).await?;
260        }
261
262        let sanitized_asset_name = sanitize_filename(&asset.name);
263        let asset_file_path = format!("{}/{}.cads.yaml", domain_dir, sanitized_asset_name);
264        let cads_yaml = CADSExporter::export_asset(asset);
265        self.storage
266            .write_file(&asset_file_path, cads_yaml.as_bytes())
267            .await?;
268
269        info!("Saved CADS asset '{}' to {}", asset.name, asset_file_path);
270        Ok(())
271    }
272
273    /// Save a BPMN model to a domain directory
274    ///
275    /// Saves the model as `{model_name}.bpmn.xml` in the specified domain directory.
276    #[cfg(feature = "bpmn")]
277    pub async fn save_bpmn_model(
278        &self,
279        workspace_path: &str,
280        domain_name: &str,
281        model: &BPMNModel,
282        xml_content: &str,
283    ) -> Result<(), StorageError> {
284        let sanitized_domain_name = sanitize_filename(domain_name);
285        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
286
287        // Ensure domain directory exists
288        if !self.storage.dir_exists(&domain_dir).await? {
289            self.storage.create_dir(&domain_dir).await?;
290        }
291
292        let sanitized_model_name = sanitize_filename(&model.name);
293        let model_file_path = format!("{}/{}.bpmn.xml", domain_dir, sanitized_model_name);
294        self.storage
295            .write_file(&model_file_path, xml_content.as_bytes())
296            .await?;
297
298        info!("Saved BPMN model '{}' to {}", model.name, model_file_path);
299        Ok(())
300    }
301
302    /// Save a DMN model to a domain directory
303    ///
304    /// Saves the model as `{model_name}.dmn.xml` in the specified domain directory.
305    #[cfg(feature = "dmn")]
306    pub async fn save_dmn_model(
307        &self,
308        workspace_path: &str,
309        domain_name: &str,
310        model: &DMNModel,
311        xml_content: &str,
312    ) -> Result<(), StorageError> {
313        let sanitized_domain_name = sanitize_filename(domain_name);
314        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
315
316        // Ensure domain directory exists
317        if !self.storage.dir_exists(&domain_dir).await? {
318            self.storage.create_dir(&domain_dir).await?;
319        }
320
321        let sanitized_model_name = sanitize_filename(&model.name);
322        let model_file_path = format!("{}/{}.dmn.xml", domain_dir, sanitized_model_name);
323        self.storage
324            .write_file(&model_file_path, xml_content.as_bytes())
325            .await?;
326
327        info!("Saved DMN model '{}' to {}", model.name, model_file_path);
328        Ok(())
329    }
330
331    /// Save an OpenAPI specification to a domain directory
332    ///
333    /// Saves the specification as `{api_name}.openapi.yaml` or `.openapi.json` in the specified domain directory.
334    #[cfg(feature = "openapi")]
335    pub async fn save_openapi_model(
336        &self,
337        workspace_path: &str,
338        domain_name: &str,
339        model: &OpenAPIModel,
340        content: &str,
341    ) -> Result<(), StorageError> {
342        let sanitized_domain_name = sanitize_filename(domain_name);
343        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
344
345        // Ensure domain directory exists
346        if !self.storage.dir_exists(&domain_dir).await? {
347            self.storage.create_dir(&domain_dir).await?;
348        }
349
350        let sanitized_api_name = sanitize_filename(&model.name);
351        let extension = match model.format {
352            OpenAPIFormat::Yaml => "yaml",
353            OpenAPIFormat::Json => "json",
354        };
355        let model_file_path = format!(
356            "{}/{}.openapi.{}",
357            domain_dir, sanitized_api_name, extension
358        );
359        self.storage
360            .write_file(&model_file_path, content.as_bytes())
361            .await?;
362
363        info!("Saved OpenAPI spec '{}' to {}", model.name, model_file_path);
364        Ok(())
365    }
366
367    // ==================== Decision and Knowledge Saving ====================
368
369    /// Save a decision to storage
370    ///
371    /// Saves the decision as a YAML file using the naming convention:
372    /// `{workspace}_{domain}_adr-{number}.madr.yaml` (with domain)
373    /// or `{workspace}_adr-{number}.madr.yaml` (without domain)
374    ///
375    /// # Arguments
376    ///
377    /// * `workspace_path` - Path to the workspace directory
378    /// * `workspace_name` - Name of the workspace for filename generation
379    /// * `decision` - The decision to save
380    pub async fn save_decision(
381        &self,
382        workspace_path: &str,
383        workspace_name: &str,
384        decision: &Decision,
385    ) -> Result<String, StorageError> {
386        let sanitized_workspace = sanitize_filename(workspace_name);
387        let number_str = format!("{:04}", decision.number);
388
389        let file_name = if let Some(ref domain) = decision.domain {
390            let sanitized_domain = sanitize_filename(domain);
391            format!(
392                "{}_{}_adr-{}.madr.yaml",
393                sanitized_workspace, sanitized_domain, number_str
394            )
395        } else {
396            format!("{}_adr-{}.madr.yaml", sanitized_workspace, number_str)
397        };
398
399        let file_path = format!("{}/{}", workspace_path, file_name);
400
401        let exporter = DecisionExporter;
402        let yaml_content = exporter.export(decision).map_err(|e| {
403            StorageError::SerializationError(format!("Failed to export decision: {}", e))
404        })?;
405
406        self.storage
407            .write_file(&file_path, yaml_content.as_bytes())
408            .await?;
409
410        info!(
411            "Saved decision '{}' ({}) to {}",
412            decision.title, decision.number, file_path
413        );
414
415        Ok(file_path)
416    }
417
418    /// Save the decision index to decisions.yaml
419    ///
420    /// # Arguments
421    ///
422    /// * `workspace_path` - Path to the workspace directory
423    /// * `index` - The decision index to save
424    pub async fn save_decision_index(
425        &self,
426        workspace_path: &str,
427        index: &DecisionIndex,
428    ) -> Result<(), StorageError> {
429        let file_path = format!("{}/decisions.yaml", workspace_path);
430
431        let exporter = DecisionExporter;
432        let yaml_content = exporter.export_index(index).map_err(|e| {
433            StorageError::SerializationError(format!("Failed to export decision index: {}", e))
434        })?;
435
436        self.storage
437            .write_file(&file_path, yaml_content.as_bytes())
438            .await?;
439
440        info!(
441            "Saved decision index with {} entries to {}",
442            index.decisions.len(),
443            file_path
444        );
445
446        Ok(())
447    }
448
449    /// Save a knowledge article to storage
450    ///
451    /// Saves the article as a YAML file using the naming convention:
452    /// `{workspace}_{domain}_kb-{number}.kb.yaml` (with domain)
453    /// or `{workspace}_kb-{number}.kb.yaml` (without domain)
454    ///
455    /// # Arguments
456    ///
457    /// * `workspace_path` - Path to the workspace directory
458    /// * `workspace_name` - Name of the workspace for filename generation
459    /// * `article` - The knowledge article to save
460    pub async fn save_knowledge(
461        &self,
462        workspace_path: &str,
463        workspace_name: &str,
464        article: &KnowledgeArticle,
465    ) -> Result<String, StorageError> {
466        let sanitized_workspace = sanitize_filename(workspace_name);
467        // Format number based on whether it's timestamp or sequential
468        let number_str = if article.is_timestamp_number() {
469            format!("{}", article.number)
470        } else {
471            format!("{:04}", article.number)
472        };
473
474        let file_name = if let Some(ref domain) = article.domain {
475            let sanitized_domain = sanitize_filename(domain);
476            format!(
477                "{}_{}_kb-{}.kb.yaml",
478                sanitized_workspace, sanitized_domain, number_str
479            )
480        } else {
481            format!("{}_kb-{}.kb.yaml", sanitized_workspace, number_str)
482        };
483
484        let file_path = format!("{}/{}", workspace_path, file_name);
485
486        let exporter = KnowledgeExporter;
487        let yaml_content = exporter.export(article).map_err(|e| {
488            StorageError::SerializationError(format!("Failed to export knowledge article: {}", e))
489        })?;
490
491        self.storage
492            .write_file(&file_path, yaml_content.as_bytes())
493            .await?;
494
495        info!(
496            "Saved knowledge article '{}' ({}) to {}",
497            article.title, article.number, file_path
498        );
499
500        Ok(file_path)
501    }
502
503    /// Save the knowledge index to knowledge.yaml
504    ///
505    /// # Arguments
506    ///
507    /// * `workspace_path` - Path to the workspace directory
508    /// * `index` - The knowledge index to save
509    pub async fn save_knowledge_index(
510        &self,
511        workspace_path: &str,
512        index: &KnowledgeIndex,
513    ) -> Result<(), StorageError> {
514        let file_path = format!("{}/knowledge.yaml", workspace_path);
515
516        let exporter = KnowledgeExporter;
517        let yaml_content = exporter.export_index(index).map_err(|e| {
518            StorageError::SerializationError(format!("Failed to export knowledge index: {}", e))
519        })?;
520
521        self.storage
522            .write_file(&file_path, yaml_content.as_bytes())
523            .await?;
524
525        info!(
526            "Saved knowledge index with {} entries to {}",
527            index.articles.len(),
528            file_path
529        );
530
531        Ok(())
532    }
533
534    /// Export a decision to Markdown
535    ///
536    /// Saves the decision as a Markdown file in the decisions/ subdirectory
537    /// using the filename format: `ADR-NNNN-slug.md`
538    ///
539    /// # Arguments
540    ///
541    /// * `workspace_path` - Path to the workspace directory
542    /// * `decision` - The decision to export
543    pub async fn export_decision_markdown(
544        &self,
545        workspace_path: &str,
546        decision: &Decision,
547    ) -> Result<String, StorageError> {
548        let decisions_dir = format!("{}/decisions", workspace_path);
549
550        // Ensure decisions directory exists
551        if !self.storage.dir_exists(&decisions_dir).await? {
552            self.storage.create_dir(&decisions_dir).await?;
553        }
554
555        // Use the Decision's built-in markdown_filename method
556        let file_name = decision.markdown_filename();
557        let file_path = format!("{}/{}", decisions_dir, file_name);
558
559        let exporter = MarkdownExporter;
560        let markdown_content = exporter.export_decision(decision).map_err(|e| {
561            StorageError::SerializationError(format!(
562                "Failed to export decision to Markdown: {}",
563                e
564            ))
565        })?;
566
567        self.storage
568            .write_file(&file_path, markdown_content.as_bytes())
569            .await?;
570
571        info!(
572            "Exported decision '{}' to Markdown: {}",
573            decision.number, file_path
574        );
575
576        Ok(file_path)
577    }
578
579    /// Export a knowledge article to Markdown
580    ///
581    /// Saves the article as a Markdown file in the knowledge/ subdirectory
582    /// using the filename format: `KB-NNNN-slug.md`
583    ///
584    /// # Arguments
585    ///
586    /// * `workspace_path` - Path to the workspace directory
587    /// * `article` - The knowledge article to export
588    pub async fn export_knowledge_markdown(
589        &self,
590        workspace_path: &str,
591        article: &KnowledgeArticle,
592    ) -> Result<String, StorageError> {
593        let knowledge_dir = format!("{}/knowledge", workspace_path);
594
595        // Ensure knowledge directory exists
596        if !self.storage.dir_exists(&knowledge_dir).await? {
597            self.storage.create_dir(&knowledge_dir).await?;
598        }
599
600        // Use the KnowledgeArticle's built-in markdown_filename method
601        let file_name = article.markdown_filename();
602        let file_path = format!("{}/{}", knowledge_dir, file_name);
603
604        let exporter = MarkdownExporter;
605        let markdown_content = exporter.export_knowledge(article).map_err(|e| {
606            StorageError::SerializationError(format!(
607                "Failed to export knowledge article to Markdown: {}",
608                e
609            ))
610        })?;
611
612        self.storage
613            .write_file(&file_path, markdown_content.as_bytes())
614            .await?;
615
616        info!(
617            "Exported knowledge article '{}' to Markdown: {}",
618            article.number, file_path
619        );
620
621        Ok(file_path)
622    }
623
624    /// Export all decisions to Markdown
625    ///
626    /// Exports all provided decisions to the decisions/ subdirectory
627    ///
628    /// # Arguments
629    ///
630    /// * `workspace_path` - Path to the workspace directory
631    /// * `decisions` - The decisions to export
632    ///
633    /// # Returns
634    ///
635    /// The number of decisions exported
636    pub async fn export_all_decisions_markdown(
637        &self,
638        workspace_path: &str,
639        decisions: &[Decision],
640    ) -> Result<usize, StorageError> {
641        let mut count = 0;
642        for decision in decisions {
643            self.export_decision_markdown(workspace_path, decision)
644                .await?;
645            count += 1;
646        }
647        Ok(count)
648    }
649
650    /// Export all knowledge articles to Markdown
651    ///
652    /// Exports all provided articles to the knowledge/ subdirectory
653    ///
654    /// # Arguments
655    ///
656    /// * `workspace_path` - Path to the workspace directory
657    /// * `articles` - The knowledge articles to export
658    ///
659    /// # Returns
660    ///
661    /// The number of articles exported
662    pub async fn export_all_knowledge_markdown(
663        &self,
664        workspace_path: &str,
665        articles: &[KnowledgeArticle],
666    ) -> Result<usize, StorageError> {
667        let mut count = 0;
668        for article in articles {
669            self.export_knowledge_markdown(workspace_path, article)
670                .await?;
671            count += 1;
672        }
673        Ok(count)
674    }
675}
676
677/// Table data to save
678#[derive(Debug, Clone)]
679pub struct TableData {
680    pub id: Uuid,
681    pub name: String,
682    pub yaml_file_path: Option<String>,
683    pub yaml_value: serde_yaml::Value,
684}
685
686/// Relationship data to save
687#[derive(Debug, Clone)]
688pub struct RelationshipData {
689    pub id: Uuid,
690    pub source_table_id: Uuid,
691    pub target_table_id: Uuid,
692    pub yaml_value: serde_yaml::Value,
693}
694
695/// Sanitize a filename by removing invalid characters
696fn sanitize_filename(name: &str) -> String {
697    name.chars()
698        .map(|c| match c {
699            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
700            _ => c,
701        })
702        .collect()
703}