data_modelling_core/model/
loader.rs

1//! Model loading functionality
2//!
3//! Loads models from storage backends, handling YAML parsing and validation.
4//!
5//! Supports both file-based loading (FileSystemStorageBackend, BrowserStorageBackend)
6//! and API-based loading (ApiStorageBackend).
7//!
8//! ## File Naming Convention
9//!
10//! All files use a flat naming pattern in the workspace root directory:
11//! - `workspace.yaml` - workspace metadata with references to all assets
12//! - `{workspace}_{domain}_{system}_{resource}.odcs.yaml` - ODCS table files
13//! - `{workspace}_{domain}_{system}_{resource}.odps.yaml` - ODPS product files
14//! - `{workspace}_{domain}_{system}_{resource}.cads.yaml` - CADS asset files
15//! - `relationships.yaml` - relationship definitions
16//!
17//! Where `{system}` is optional if the resource is at the domain level.
18
19#[cfg(feature = "bpmn")]
20use crate::import::bpmn::BPMNImporter;
21use crate::import::decision::DecisionImporter;
22#[cfg(feature = "dmn")]
23use crate::import::dmn::DMNImporter;
24use crate::import::knowledge::KnowledgeImporter;
25#[cfg(feature = "openapi")]
26use crate::import::openapi::OpenAPIImporter;
27use crate::import::{cads::CADSImporter, odcs::ODCSImporter, odps::ODPSImporter};
28#[cfg(feature = "bpmn")]
29use crate::models::bpmn::BPMNModel;
30use crate::models::decision::{Decision, DecisionIndex};
31#[cfg(feature = "dmn")]
32use crate::models::dmn::DMNModel;
33use crate::models::domain_config::DomainConfig;
34use crate::models::knowledge::{KnowledgeArticle, KnowledgeIndex};
35#[cfg(feature = "openapi")]
36use crate::models::openapi::{OpenAPIFormat, OpenAPIModel};
37use crate::models::workspace::{AssetType, Workspace};
38use crate::models::{cads::CADSAsset, domain::Domain, odps::ODPSDataProduct, table::Table};
39use crate::storage::{StorageBackend, StorageError};
40use anyhow::Result;
41use serde::{Deserialize, Serialize};
42use serde_yaml;
43use std::collections::HashMap;
44use tracing::{info, warn};
45use uuid::Uuid;
46
47/// Model loader that uses a storage backend
48pub struct ModelLoader<B: StorageBackend> {
49    storage: B,
50}
51
52impl<B: StorageBackend> ModelLoader<B> {
53    /// Create a new model loader with the given storage backend
54    pub fn new(storage: B) -> Self {
55        Self { storage }
56    }
57
58    /// Load a model from storage
59    ///
60    /// For file-based backends (FileSystemStorageBackend, BrowserStorageBackend):
61    /// - Loads from flat files in workspace root using naming convention
62    /// - Loads from `relationships.yaml` file
63    ///
64    /// For API backend (ApiStorageBackend), use `load_model_from_api()` instead.
65    ///
66    /// Returns the loaded model data and a list of orphaned relationships
67    /// (relationships that reference non-existent tables).
68    pub async fn load_model(&self, workspace_path: &str) -> Result<ModelLoadResult, StorageError> {
69        // File-based loading implementation
70        self.load_model_from_files(workspace_path).await
71    }
72
73    /// Load model from file-based storage using flat file naming convention
74    async fn load_model_from_files(
75        &self,
76        workspace_path: &str,
77    ) -> Result<ModelLoadResult, StorageError> {
78        // Load tables from flat YAML files in workspace root
79        let mut tables = Vec::new();
80        let mut table_ids: HashMap<Uuid, String> = HashMap::new();
81
82        let files = self.storage.list_files(workspace_path).await?;
83        for file_name in files {
84            // Only load supported asset files (skip workspace.yaml, relationships.yaml, etc.)
85            if let Some(asset_type) = AssetType::from_filename(&file_name) {
86                // Skip workspace-level files and non-ODCS files for table loading
87                if asset_type == AssetType::Odcs {
88                    let file_path = format!("{}/{}", workspace_path, file_name);
89                    match self.load_table_from_yaml(&file_path, workspace_path).await {
90                        Ok(table_data) => {
91                            table_ids.insert(table_data.id, table_data.name.clone());
92                            tables.push(table_data);
93                        }
94                        Err(e) => {
95                            warn!("Failed to load table from {}: {}", file_path, e);
96                        }
97                    }
98                }
99            }
100        }
101
102        info!(
103            "Loaded {} tables from workspace {}",
104            tables.len(),
105            workspace_path
106        );
107
108        // Load relationships from control file
109        let relationships_file = format!("{}/relationships.yaml", workspace_path);
110        let mut relationships = Vec::new();
111        let mut orphaned_relationships = Vec::new();
112
113        if self.storage.file_exists(&relationships_file).await? {
114            match self.load_relationships_from_yaml(&relationships_file).await {
115                Ok(loaded_rels) => {
116                    // Separate valid and orphaned relationships
117                    for rel in loaded_rels {
118                        let source_exists = table_ids.contains_key(&rel.source_table_id);
119                        let target_exists = table_ids.contains_key(&rel.target_table_id);
120
121                        if source_exists && target_exists {
122                            relationships.push(rel.clone());
123                        } else {
124                            orphaned_relationships.push(rel.clone());
125                            warn!(
126                                "Orphaned relationship {}: source={} (exists: {}), target={} (exists: {})",
127                                rel.id,
128                                rel.source_table_id,
129                                source_exists,
130                                rel.target_table_id,
131                                target_exists
132                            );
133                        }
134                    }
135                }
136                Err(e) => {
137                    warn!(
138                        "Failed to load relationships from {}: {}",
139                        relationships_file, e
140                    );
141                }
142            }
143        }
144
145        info!(
146            "Loaded {} relationships ({} orphaned) from workspace {}",
147            relationships.len(),
148            orphaned_relationships.len(),
149            workspace_path
150        );
151
152        Ok(ModelLoadResult {
153            tables,
154            relationships,
155            orphaned_relationships,
156        })
157    }
158
159    /// Load a table from a YAML file
160    ///
161    /// Uses ODCSImporter to fully parse the table structure, including all columns,
162    /// metadata, and nested properties. This ensures complete table data is loaded.
163    async fn load_table_from_yaml(
164        &self,
165        yaml_path: &str,
166        workspace_path: &str,
167    ) -> Result<TableData, StorageError> {
168        let content = self.storage.read_file(yaml_path).await?;
169        let yaml_content = String::from_utf8(content)
170            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
171
172        // Use ODCSImporter to fully parse the table structure
173        let mut importer = crate::import::odcs::ODCSImporter::new();
174        let (table, parse_errors) = importer.parse_table(&yaml_content).map_err(|e| {
175            StorageError::SerializationError(format!("Failed to parse ODCS YAML: {}", e))
176        })?;
177
178        // Log any parse warnings/errors but don't fail if table was successfully parsed
179        if !parse_errors.is_empty() {
180            warn!(
181                "Table '{}' parsed with {} warnings/errors",
182                table.name,
183                parse_errors.len()
184            );
185        }
186
187        // Calculate relative path
188        let relative_path = yaml_path
189            .strip_prefix(workspace_path)
190            .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
191            .unwrap_or_else(|| yaml_path.to_string());
192
193        Ok(TableData {
194            id: table.id,
195            name: table.name,
196            yaml_file_path: Some(relative_path),
197            yaml_content,
198        })
199    }
200
201    /// Load relationships from YAML file
202    async fn load_relationships_from_yaml(
203        &self,
204        yaml_path: &str,
205    ) -> Result<Vec<RelationshipData>, StorageError> {
206        let content = self.storage.read_file(yaml_path).await?;
207        let yaml_content = String::from_utf8(content)
208            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
209
210        let data: serde_yaml::Value = serde_yaml::from_str(&yaml_content).map_err(|e| {
211            StorageError::SerializationError(format!("Failed to parse YAML: {}", e))
212        })?;
213
214        let mut relationships = Vec::new();
215
216        // Handle both formats: direct array or object with "relationships" key
217        let rels_array = data
218            .get("relationships")
219            .and_then(|v| v.as_sequence())
220            .or_else(|| data.as_sequence());
221
222        if let Some(rels_array) = rels_array {
223            for rel_data in rels_array {
224                match self.parse_relationship(rel_data) {
225                    Ok(rel) => relationships.push(rel),
226                    Err(e) => {
227                        warn!("Failed to parse relationship: {}", e);
228                    }
229                }
230            }
231        }
232
233        Ok(relationships)
234    }
235
236    /// Parse a relationship from YAML value
237    fn parse_relationship(
238        &self,
239        data: &serde_yaml::Value,
240    ) -> Result<RelationshipData, StorageError> {
241        let source_table_id = data
242            .get("source_table_id")
243            .and_then(|v| v.as_str())
244            .and_then(|s| Uuid::parse_str(s).ok())
245            .ok_or_else(|| {
246                StorageError::SerializationError("Missing source_table_id".to_string())
247            })?;
248
249        let target_table_id = data
250            .get("target_table_id")
251            .and_then(|v| v.as_str())
252            .and_then(|s| Uuid::parse_str(s).ok())
253            .ok_or_else(|| {
254                StorageError::SerializationError("Missing target_table_id".to_string())
255            })?;
256
257        // Parse existing UUID or generate deterministic one based on source and target table IDs
258        let id = data
259            .get("id")
260            .and_then(|v| v.as_str())
261            .and_then(|s| Uuid::parse_str(s).ok())
262            .unwrap_or_else(|| {
263                crate::models::relationship::Relationship::generate_id(
264                    source_table_id,
265                    target_table_id,
266                )
267            });
268
269        Ok(RelationshipData {
270            id,
271            source_table_id,
272            target_table_id,
273        })
274    }
275
276    /// Load all domains from storage
277    ///
278    /// Loads domains and assets from flat files in the workspace root directory.
279    /// Uses the file naming convention: {workspace}_{domain}_{system}_{resource}.xxx.yaml
280    ///
281    /// Domain and system information is extracted from filenames and the workspace.yaml file.
282    pub async fn load_domains(
283        &self,
284        workspace_path: &str,
285    ) -> Result<DomainLoadResult, StorageError> {
286        let mut domains = Vec::new();
287        let mut tables = HashMap::new();
288        let mut odps_products = HashMap::new();
289        let mut cads_assets = HashMap::new();
290
291        // Load workspace.yaml to get domain/system structure
292        let workspace = self.load_workspace(workspace_path).await?;
293
294        // If workspace.yaml exists, use its domain definitions
295        if let Some(ws) = &workspace {
296            for domain_ref in &ws.domains {
297                domains.push(Domain::new(domain_ref.name.clone()));
298            }
299        }
300
301        // Load all flat files from workspace root
302        let files = self.storage.list_files(workspace_path).await?;
303
304        for file_name in files {
305            let Some(asset_type) = AssetType::from_filename(&file_name) else {
306                continue;
307            };
308
309            // Skip workspace-level files
310            if asset_type.is_workspace_level() {
311                continue;
312            }
313
314            let file_path = format!("{}/{}", workspace_path, file_name);
315
316            match asset_type {
317                AssetType::Odcs => {
318                    // Load ODCS table
319                    match self.load_odcs_table_from_file(&file_path).await {
320                        Ok(table) => {
321                            tables.insert(table.id, table);
322                        }
323                        Err(e) => {
324                            warn!("Failed to load ODCS table from {}: {}", file_path, e);
325                        }
326                    }
327                }
328                AssetType::Odps => {
329                    // Load ODPS product
330                    match self.load_odps_product_from_file(&file_path).await {
331                        Ok(product) => {
332                            odps_products.insert(
333                                Uuid::parse_str(&product.id).unwrap_or_else(|_| Uuid::new_v4()),
334                                product,
335                            );
336                        }
337                        Err(e) => {
338                            warn!("Failed to load ODPS product from {}: {}", file_path, e);
339                        }
340                    }
341                }
342                AssetType::Cads => {
343                    // Load CADS asset
344                    match self.load_cads_asset_from_file(&file_path).await {
345                        Ok(asset) => {
346                            cads_assets.insert(
347                                Uuid::parse_str(&asset.id).unwrap_or_else(|_| Uuid::new_v4()),
348                                asset,
349                            );
350                        }
351                        Err(e) => {
352                            warn!("Failed to load CADS asset from {}: {}", file_path, e);
353                        }
354                    }
355                }
356                _ => {
357                    // Skip other asset types for now (BPMN, DMN, OpenAPI handled separately)
358                }
359            }
360        }
361
362        info!(
363            "Loaded {} domains, {} tables, {} ODPS products, {} CADS assets from workspace {}",
364            domains.len(),
365            tables.len(),
366            odps_products.len(),
367            cads_assets.len(),
368            workspace_path
369        );
370
371        Ok(DomainLoadResult {
372            domains,
373            tables,
374            odps_products,
375            cads_assets,
376        })
377    }
378
379    /// Load an ODCS table from a file
380    async fn load_odcs_table_from_file(&self, file_path: &str) -> Result<Table, StorageError> {
381        let content = self.storage.read_file(file_path).await?;
382        let yaml_content = String::from_utf8(content)
383            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
384
385        let mut importer = ODCSImporter::new();
386        let (table, _parse_errors) = importer.parse_table(&yaml_content).map_err(|e| {
387            StorageError::SerializationError(format!("Failed to parse ODCS table: {}", e))
388        })?;
389
390        Ok(table)
391    }
392
393    /// Load an ODPS product from a file
394    async fn load_odps_product_from_file(
395        &self,
396        file_path: &str,
397    ) -> Result<ODPSDataProduct, StorageError> {
398        let content = self.storage.read_file(file_path).await?;
399        let yaml_content = String::from_utf8(content)
400            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
401
402        let importer = ODPSImporter::new();
403        importer
404            .import(&yaml_content)
405            .map_err(|e| StorageError::SerializationError(format!("Failed to parse ODPS: {}", e)))
406    }
407
408    /// Load a CADS asset from a file
409    async fn load_cads_asset_from_file(&self, file_path: &str) -> Result<CADSAsset, StorageError> {
410        let content = self.storage.read_file(file_path).await?;
411        let yaml_content = String::from_utf8(content)
412            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
413
414        let importer = CADSImporter::new();
415        importer.import(&yaml_content).map_err(|e| {
416            StorageError::SerializationError(format!("Failed to parse CADS asset: {}", e))
417        })
418    }
419
420    /// Load all domains from explicit domain directory names (DEPRECATED)
421    ///
422    /// This method is deprecated. Use load_domains() with flat file structure instead.
423    #[deprecated(
424        since = "2.0.0",
425        note = "Use load_domains() with flat file structure instead"
426    )]
427    #[allow(dead_code)]
428    async fn load_domains_legacy(
429        &self,
430        workspace_path: &str,
431    ) -> Result<DomainLoadResult, StorageError> {
432        let domains = Vec::new();
433        let tables = HashMap::new();
434        let odps_products = HashMap::new();
435        let cads_assets = HashMap::new();
436
437        info!(
438            "Legacy domain loading is deprecated. Use flat file structure instead. Workspace: {}",
439            workspace_path
440        );
441
442        Ok(DomainLoadResult {
443            domains,
444            tables,
445            odps_products,
446            cads_assets,
447        })
448    }
449
450    /// Load domains from explicit domain directory names (DEPRECATED)
451    ///
452    /// This method is deprecated. Use load_domains() with flat file structure instead.
453    #[deprecated(
454        since = "2.0.0",
455        note = "Use load_domains() with flat file structure instead. Domain directories are no longer supported."
456    )]
457    #[allow(dead_code)]
458    pub async fn load_domains_from_list(
459        &self,
460        workspace_path: &str,
461        _domain_directory_names: &[String],
462    ) -> Result<DomainLoadResult, StorageError> {
463        warn!(
464            "load_domains_from_list is deprecated. Using flat file structure for workspace: {}",
465            workspace_path
466        );
467
468        // Delegate to the new flat file loading
469        self.load_domains(workspace_path).await
470    }
471
472    /// Load a single domain from a domain directory (DEPRECATED)
473    #[deprecated(
474        since = "2.0.0",
475        note = "Domain directories are no longer supported. Use flat file structure."
476    )]
477    #[allow(dead_code)]
478    async fn load_domain_legacy(&self, domain_dir: &str) -> Result<Domain, StorageError> {
479        let domain_yaml_path = format!("{}/domain.yaml", domain_dir);
480        let content = self.storage.read_file(&domain_yaml_path).await?;
481        let yaml_content = String::from_utf8(content)
482            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
483
484        Domain::from_yaml(&yaml_content).map_err(|e| {
485            StorageError::SerializationError(format!("Failed to parse domain YAML: {}", e))
486        })
487    }
488
489    /// Load ODCS tables from a domain directory (DEPRECATED)
490    #[deprecated(
491        since = "2.0.0",
492        note = "Domain directories are no longer supported. Use flat file structure."
493    )]
494    #[allow(dead_code)]
495    async fn load_domain_odcs_tables_legacy(
496        &self,
497        domain_dir: &str,
498    ) -> Result<Vec<Table>, StorageError> {
499        let mut tables = Vec::new();
500        let files = self.storage.list_files(domain_dir).await?;
501
502        for file_name in files {
503            if file_name.ends_with(".odcs.yaml") || file_name.ends_with(".odcs.yml") {
504                let file_path = format!("{}/{}", domain_dir, file_name);
505                match self.load_table_from_yaml(&file_path, domain_dir).await {
506                    Ok(table_data) => {
507                        // Parse the table from ODCS YAML
508                        let mut importer = ODCSImporter::new();
509                        match importer.parse_table(&table_data.yaml_content) {
510                            Ok((table, _parse_errors)) => {
511                                tables.push(table);
512                            }
513                            Err(e) => {
514                                warn!("Failed to parse ODCS table from {}: {}", file_path, e);
515                            }
516                        }
517                    }
518                    Err(e) => {
519                        warn!("Failed to load ODCS table from {}: {}", file_path, e);
520                    }
521                }
522            }
523        }
524
525        Ok(tables)
526    }
527
528    /// Load ODPS products from a domain directory (DEPRECATED)
529    #[deprecated(
530        since = "2.0.0",
531        note = "Domain directories are no longer supported. Use flat file structure."
532    )]
533    #[allow(dead_code)]
534    async fn load_domain_odps_products_legacy(
535        &self,
536        domain_dir: &str,
537    ) -> Result<Vec<ODPSDataProduct>, StorageError> {
538        let mut products = Vec::new();
539        let files = self.storage.list_files(domain_dir).await?;
540
541        for file_name in files {
542            if file_name.ends_with(".odps.yaml") || file_name.ends_with(".odps.yml") {
543                let file_path = format!("{}/{}", domain_dir, file_name);
544                let content = self.storage.read_file(&file_path).await?;
545                let yaml_content = String::from_utf8(content).map_err(|e| {
546                    StorageError::SerializationError(format!("Invalid UTF-8: {}", e))
547                })?;
548
549                let importer = ODPSImporter::new();
550                match importer.import(&yaml_content) {
551                    Ok(product) => {
552                        products.push(product);
553                    }
554                    Err(e) => {
555                        warn!("Failed to parse ODPS product from {}: {}", file_path, e);
556                    }
557                }
558            }
559        }
560
561        Ok(products)
562    }
563
564    /// Load CADS assets from a domain directory (DEPRECATED)
565    #[deprecated(
566        since = "2.0.0",
567        note = "Domain directories are no longer supported. Use flat file structure."
568    )]
569    #[allow(dead_code)]
570    async fn load_domain_cads_assets_legacy(
571        &self,
572        domain_dir: &str,
573    ) -> Result<Vec<CADSAsset>, StorageError> {
574        let mut assets = Vec::new();
575        let files = self.storage.list_files(domain_dir).await?;
576
577        for file_name in files {
578            if file_name.ends_with(".cads.yaml") || file_name.ends_with(".cads.yml") {
579                let file_path = format!("{}/{}", domain_dir, file_name);
580                let content = self.storage.read_file(&file_path).await?;
581                let yaml_content = String::from_utf8(content).map_err(|e| {
582                    StorageError::SerializationError(format!("Invalid UTF-8: {}", e))
583                })?;
584
585                let importer = CADSImporter::new();
586                match importer.import(&yaml_content) {
587                    Ok(asset) => {
588                        assets.push(asset);
589                    }
590                    Err(e) => {
591                        warn!("Failed to parse CADS asset from {}: {}", file_path, e);
592                    }
593                }
594            }
595        }
596
597        Ok(assets)
598    }
599
600    /// Load all BPMN models from workspace using flat file structure
601    #[cfg(feature = "bpmn")]
602    pub async fn load_bpmn_models(
603        &self,
604        workspace_path: &str,
605        _domain_name: &str,
606    ) -> Result<Vec<BPMNModel>, StorageError> {
607        let mut models = Vec::new();
608        let files = self.storage.list_files(workspace_path).await?;
609
610        for file_name in files {
611            if file_name.ends_with(".bpmn.xml") {
612                let file_path = format!("{}/{}", workspace_path, file_name);
613                match self.load_bpmn_model_from_file(&file_path, &file_name).await {
614                    Ok(model) => models.push(model),
615                    Err(e) => {
616                        warn!("Failed to load BPMN model from {}: {}", file_path, e);
617                    }
618                }
619            }
620        }
621
622        Ok(models)
623    }
624
625    /// Load a specific BPMN model from a file
626    #[cfg(feature = "bpmn")]
627    async fn load_bpmn_model_from_file(
628        &self,
629        file_path: &str,
630        file_name: &str,
631    ) -> Result<BPMNModel, StorageError> {
632        let content = self.storage.read_file(file_path).await?;
633        let xml_content = String::from_utf8(content)
634            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
635
636        // Extract model name from filename (remove .bpmn.xml extension)
637        let model_name = file_name
638            .strip_suffix(".bpmn.xml")
639            .unwrap_or(file_name)
640            .to_string();
641
642        // Generate a domain ID (can be extracted from filename if using naming convention)
643        let domain_id = Uuid::new_v4();
644
645        // Import using BPMNImporter
646        let mut importer = BPMNImporter::new();
647        let model = importer
648            .import(&xml_content, domain_id, Some(&model_name))
649            .map_err(|e| {
650                StorageError::SerializationError(format!("Failed to import BPMN model: {}", e))
651            })?;
652
653        Ok(model)
654    }
655
656    /// Load a specific BPMN model by name from a domain directory (DEPRECATED)
657    #[cfg(feature = "bpmn")]
658    #[deprecated(
659        since = "2.0.0",
660        note = "Use load_bpmn_model_from_file with flat file structure instead"
661    )]
662    #[allow(dead_code)]
663    pub async fn load_bpmn_model(
664        &self,
665        domain_dir: &str,
666        file_name: &str,
667    ) -> Result<BPMNModel, StorageError> {
668        let file_path = format!("{}/{}", domain_dir, file_name);
669        self.load_bpmn_model_from_file(&file_path, file_name).await
670    }
671
672    /// Load BPMN XML content from workspace
673    #[cfg(feature = "bpmn")]
674    pub async fn load_bpmn_xml(
675        &self,
676        workspace_path: &str,
677        _domain_name: &str,
678        model_name: &str,
679    ) -> Result<String, StorageError> {
680        let sanitized_model_name = sanitize_filename(model_name);
681        // Try to find the file with any naming pattern
682        let files = self.storage.list_files(workspace_path).await?;
683
684        for file_name in files {
685            if file_name.ends_with(".bpmn.xml") && file_name.contains(&sanitized_model_name) {
686                let file_path = format!("{}/{}", workspace_path, file_name);
687                let content = self.storage.read_file(&file_path).await?;
688                return String::from_utf8(content).map_err(|e| {
689                    StorageError::SerializationError(format!("Invalid UTF-8: {}", e))
690                });
691            }
692        }
693
694        Err(StorageError::IoError(format!(
695            "BPMN model '{}' not found in workspace",
696            model_name
697        )))
698    }
699
700    /// Load all DMN models from workspace using flat file structure
701    #[cfg(feature = "dmn")]
702    pub async fn load_dmn_models(
703        &self,
704        workspace_path: &str,
705        _domain_name: &str,
706    ) -> Result<Vec<DMNModel>, StorageError> {
707        let mut models = Vec::new();
708        let files = self.storage.list_files(workspace_path).await?;
709
710        for file_name in files {
711            if file_name.ends_with(".dmn.xml") {
712                let file_path = format!("{}/{}", workspace_path, file_name);
713                match self.load_dmn_model_from_file(&file_path, &file_name).await {
714                    Ok(model) => models.push(model),
715                    Err(e) => {
716                        warn!("Failed to load DMN model from {}: {}", file_path, e);
717                    }
718                }
719            }
720        }
721
722        Ok(models)
723    }
724
725    /// Load a specific DMN model from a file
726    #[cfg(feature = "dmn")]
727    async fn load_dmn_model_from_file(
728        &self,
729        file_path: &str,
730        file_name: &str,
731    ) -> Result<DMNModel, StorageError> {
732        let content = self.storage.read_file(file_path).await?;
733        let xml_content = String::from_utf8(content)
734            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
735
736        // Extract model name from filename (remove .dmn.xml extension)
737        let model_name = file_name
738            .strip_suffix(".dmn.xml")
739            .unwrap_or(file_name)
740            .to_string();
741
742        // Generate a domain ID (can be extracted from filename if using naming convention)
743        let domain_id = Uuid::new_v4();
744
745        // Import using DMNImporter
746        let mut importer = DMNImporter::new();
747        let model = importer
748            .import(&xml_content, domain_id, Some(&model_name))
749            .map_err(|e| {
750                StorageError::SerializationError(format!("Failed to import DMN model: {}", e))
751            })?;
752
753        Ok(model)
754    }
755
756    /// Load a specific DMN model by name from a domain directory (DEPRECATED)
757    #[cfg(feature = "dmn")]
758    #[deprecated(
759        since = "2.0.0",
760        note = "Use load_dmn_model_from_file with flat file structure instead"
761    )]
762    #[allow(dead_code)]
763    pub async fn load_dmn_model(
764        &self,
765        domain_dir: &str,
766        file_name: &str,
767    ) -> Result<DMNModel, StorageError> {
768        let file_path = format!("{}/{}", domain_dir, file_name);
769        self.load_dmn_model_from_file(&file_path, file_name).await
770    }
771
772    /// Load DMN XML content from workspace
773    #[cfg(feature = "dmn")]
774    pub async fn load_dmn_xml(
775        &self,
776        workspace_path: &str,
777        _domain_name: &str,
778        model_name: &str,
779    ) -> Result<String, StorageError> {
780        let sanitized_model_name = sanitize_filename(model_name);
781        let files = self.storage.list_files(workspace_path).await?;
782
783        for file_name in files {
784            if file_name.ends_with(".dmn.xml") && file_name.contains(&sanitized_model_name) {
785                let file_path = format!("{}/{}", workspace_path, file_name);
786                let content = self.storage.read_file(&file_path).await?;
787                return String::from_utf8(content).map_err(|e| {
788                    StorageError::SerializationError(format!("Invalid UTF-8: {}", e))
789                });
790            }
791        }
792
793        Err(StorageError::IoError(format!(
794            "DMN model '{}' not found in workspace",
795            model_name
796        )))
797    }
798
799    /// Load all OpenAPI specifications from workspace using flat file structure
800    #[cfg(feature = "openapi")]
801    pub async fn load_openapi_models(
802        &self,
803        workspace_path: &str,
804        _domain_name: &str,
805    ) -> Result<Vec<OpenAPIModel>, StorageError> {
806        let mut models = Vec::new();
807        let files = self.storage.list_files(workspace_path).await?;
808
809        for file_name in files {
810            if file_name.ends_with(".openapi.yaml")
811                || file_name.ends_with(".openapi.yml")
812                || file_name.ends_with(".openapi.json")
813            {
814                let file_path = format!("{}/{}", workspace_path, file_name);
815                match self
816                    .load_openapi_model_from_file(&file_path, &file_name)
817                    .await
818                {
819                    Ok(model) => models.push(model),
820                    Err(e) => {
821                        warn!("Failed to load OpenAPI spec from {}: {}", file_path, e);
822                    }
823                }
824            }
825        }
826
827        Ok(models)
828    }
829
830    /// Load a specific OpenAPI model from a file
831    #[cfg(feature = "openapi")]
832    async fn load_openapi_model_from_file(
833        &self,
834        file_path: &str,
835        file_name: &str,
836    ) -> Result<OpenAPIModel, StorageError> {
837        let content = self.storage.read_file(file_path).await?;
838        let spec_content = String::from_utf8(content)
839            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
840
841        // Extract API name from filename (remove .openapi.yaml/.openapi.json extension)
842        let api_name = file_name
843            .strip_suffix(".openapi.yaml")
844            .or_else(|| file_name.strip_suffix(".openapi.yml"))
845            .or_else(|| file_name.strip_suffix(".openapi.json"))
846            .unwrap_or(file_name)
847            .to_string();
848
849        // Generate a domain ID (can be extracted from filename if using naming convention)
850        let domain_id = Uuid::new_v4();
851
852        // Import using OpenAPIImporter
853        let mut importer = OpenAPIImporter::new();
854        let model = importer
855            .import(&spec_content, domain_id, Some(&api_name))
856            .map_err(|e| {
857                StorageError::SerializationError(format!("Failed to import OpenAPI spec: {}", e))
858            })?;
859
860        Ok(model)
861    }
862
863    /// Load a specific OpenAPI model by name from a domain directory (DEPRECATED)
864    #[cfg(feature = "openapi")]
865    #[deprecated(
866        since = "2.0.0",
867        note = "Use load_openapi_model_from_file with flat file structure instead"
868    )]
869    #[allow(dead_code)]
870    pub async fn load_openapi_model(
871        &self,
872        domain_dir: &str,
873        file_name: &str,
874    ) -> Result<OpenAPIModel, StorageError> {
875        let file_path = format!("{}/{}", domain_dir, file_name);
876        self.load_openapi_model_from_file(&file_path, file_name)
877            .await
878    }
879
880    /// Load OpenAPI content from workspace
881    #[cfg(feature = "openapi")]
882    pub async fn load_openapi_content(
883        &self,
884        workspace_path: &str,
885        _domain_name: &str,
886        api_name: &str,
887        format: Option<OpenAPIFormat>,
888    ) -> Result<String, StorageError> {
889        let sanitized_api_name = sanitize_filename(api_name);
890
891        // Try to find the file with the requested format, or any format
892        let extensions: Vec<&str> = if let Some(fmt) = format {
893            match fmt {
894                OpenAPIFormat::Yaml => vec!["yaml", "yml"],
895                OpenAPIFormat::Json => vec!["json"],
896            }
897        } else {
898            vec!["yaml", "yml", "json"]
899        };
900
901        let files = self.storage.list_files(workspace_path).await?;
902
903        for file_name in files {
904            for ext in &extensions {
905                let suffix = format!(".openapi.{}", ext);
906                if file_name.ends_with(&suffix) && file_name.contains(&sanitized_api_name) {
907                    let file_path = format!("{}/{}", workspace_path, file_name);
908                    let content = self.storage.read_file(&file_path).await?;
909                    return String::from_utf8(content).map_err(|e| {
910                        StorageError::SerializationError(format!("Invalid UTF-8: {}", e))
911                    });
912                }
913            }
914        }
915
916        Err(StorageError::IoError(format!(
917            "OpenAPI spec '{}' not found in workspace",
918            api_name
919        )))
920    }
921
922    // ==================== Decision and Knowledge Loading ====================
923
924    /// Load all decisions from workspace using flat file structure
925    ///
926    /// Loads all `.madr.yaml` files from the workspace directory and parses them
927    /// into Decision structs using DecisionImporter.
928    ///
929    /// # Arguments
930    ///
931    /// * `workspace_path` - Path to the workspace directory
932    ///
933    /// # Returns
934    ///
935    /// A DecisionLoadResult containing loaded decisions and any errors encountered
936    pub async fn load_decisions(
937        &self,
938        workspace_path: &str,
939    ) -> Result<DecisionLoadResult, StorageError> {
940        let mut decisions = Vec::new();
941        let mut load_errors = Vec::new();
942
943        let files = self.storage.list_files(workspace_path).await?;
944        let importer = DecisionImporter;
945
946        for file_name in files {
947            if let Some(AssetType::Decision) = AssetType::from_filename(&file_name) {
948                let file_path = format!("{}/{}", workspace_path, file_name);
949                match self.storage.read_file(&file_path).await {
950                    Ok(content) => {
951                        let yaml_content = match String::from_utf8(content) {
952                            Ok(s) => s,
953                            Err(e) => {
954                                load_errors.push(DecisionLoadError {
955                                    file_path: file_path.clone(),
956                                    error: format!("Invalid UTF-8: {}", e),
957                                });
958                                continue;
959                            }
960                        };
961
962                        match importer.import(&yaml_content) {
963                            Ok(decision) => {
964                                decisions.push(decision);
965                            }
966                            Err(e) => {
967                                load_errors.push(DecisionLoadError {
968                                    file_path: file_path.clone(),
969                                    error: format!("Failed to import decision: {}", e),
970                                });
971                            }
972                        }
973                    }
974                    Err(e) => {
975                        load_errors.push(DecisionLoadError {
976                            file_path: file_path.clone(),
977                            error: format!("Failed to read file: {}", e),
978                        });
979                    }
980                }
981            }
982        }
983
984        info!(
985            "Loaded {} decisions ({} errors) from workspace {}",
986            decisions.len(),
987            load_errors.len(),
988            workspace_path
989        );
990
991        Ok(DecisionLoadResult {
992            decisions,
993            errors: load_errors,
994        })
995    }
996
997    /// Load the decision index from decisions.yaml
998    ///
999    /// # Arguments
1000    ///
1001    /// * `workspace_path` - Path to the workspace directory
1002    ///
1003    /// # Returns
1004    ///
1005    /// The DecisionIndex if found, or None if decisions.yaml doesn't exist
1006    pub async fn load_decision_index(
1007        &self,
1008        workspace_path: &str,
1009    ) -> Result<Option<DecisionIndex>, StorageError> {
1010        let index_file = format!("{}/decisions.yaml", workspace_path);
1011
1012        if !self.storage.file_exists(&index_file).await? {
1013            return Ok(None);
1014        }
1015
1016        let content = self.storage.read_file(&index_file).await?;
1017        let yaml_content = String::from_utf8(content)
1018            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
1019
1020        let importer = DecisionImporter;
1021        let index = importer.import_index(&yaml_content).map_err(|e| {
1022            StorageError::SerializationError(format!("Failed to parse decisions.yaml: {}", e))
1023        })?;
1024
1025        Ok(Some(index))
1026    }
1027
1028    /// Load all knowledge articles from workspace using flat file structure
1029    ///
1030    /// Loads all `.kb.yaml` files from the workspace directory and parses them
1031    /// into KnowledgeArticle structs using KnowledgeImporter.
1032    ///
1033    /// # Arguments
1034    ///
1035    /// * `workspace_path` - Path to the workspace directory
1036    ///
1037    /// # Returns
1038    ///
1039    /// A KnowledgeLoadResult containing loaded articles and any errors encountered
1040    pub async fn load_knowledge(
1041        &self,
1042        workspace_path: &str,
1043    ) -> Result<KnowledgeLoadResult, StorageError> {
1044        let mut articles = Vec::new();
1045        let mut load_errors = Vec::new();
1046
1047        let files = self.storage.list_files(workspace_path).await?;
1048        let importer = KnowledgeImporter;
1049
1050        for file_name in files {
1051            if let Some(AssetType::Knowledge) = AssetType::from_filename(&file_name) {
1052                let file_path = format!("{}/{}", workspace_path, file_name);
1053                match self.storage.read_file(&file_path).await {
1054                    Ok(content) => {
1055                        let yaml_content = match String::from_utf8(content) {
1056                            Ok(s) => s,
1057                            Err(e) => {
1058                                load_errors.push(KnowledgeLoadError {
1059                                    file_path: file_path.clone(),
1060                                    error: format!("Invalid UTF-8: {}", e),
1061                                });
1062                                continue;
1063                            }
1064                        };
1065
1066                        match importer.import(&yaml_content) {
1067                            Ok(article) => {
1068                                articles.push(article);
1069                            }
1070                            Err(e) => {
1071                                load_errors.push(KnowledgeLoadError {
1072                                    file_path: file_path.clone(),
1073                                    error: format!("Failed to import knowledge article: {}", e),
1074                                });
1075                            }
1076                        }
1077                    }
1078                    Err(e) => {
1079                        load_errors.push(KnowledgeLoadError {
1080                            file_path: file_path.clone(),
1081                            error: format!("Failed to read file: {}", e),
1082                        });
1083                    }
1084                }
1085            }
1086        }
1087
1088        info!(
1089            "Loaded {} knowledge articles ({} errors) from workspace {}",
1090            articles.len(),
1091            load_errors.len(),
1092            workspace_path
1093        );
1094
1095        Ok(KnowledgeLoadResult {
1096            articles,
1097            errors: load_errors,
1098        })
1099    }
1100
1101    /// Load the knowledge index from knowledge.yaml
1102    ///
1103    /// # Arguments
1104    ///
1105    /// * `workspace_path` - Path to the workspace directory
1106    ///
1107    /// # Returns
1108    ///
1109    /// The KnowledgeIndex if found, or None if knowledge.yaml doesn't exist
1110    pub async fn load_knowledge_index(
1111        &self,
1112        workspace_path: &str,
1113    ) -> Result<Option<KnowledgeIndex>, StorageError> {
1114        let index_file = format!("{}/knowledge.yaml", workspace_path);
1115
1116        if !self.storage.file_exists(&index_file).await? {
1117            return Ok(None);
1118        }
1119
1120        let content = self.storage.read_file(&index_file).await?;
1121        let yaml_content = String::from_utf8(content)
1122            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
1123
1124        let importer = KnowledgeImporter;
1125        let index = importer.import_index(&yaml_content).map_err(|e| {
1126            StorageError::SerializationError(format!("Failed to parse knowledge.yaml: {}", e))
1127        })?;
1128
1129        Ok(Some(index))
1130    }
1131
1132    /// Load knowledge articles by domain
1133    ///
1134    /// Filters knowledge articles by their domain field.
1135    ///
1136    /// # Arguments
1137    ///
1138    /// * `workspace_path` - Path to the workspace directory
1139    /// * `domain` - Domain name to filter by
1140    ///
1141    /// # Returns
1142    ///
1143    /// A KnowledgeLoadResult containing matching articles and any errors encountered
1144    pub async fn load_knowledge_by_domain(
1145        &self,
1146        workspace_path: &str,
1147        domain: &str,
1148    ) -> Result<KnowledgeLoadResult, StorageError> {
1149        let result = self.load_knowledge(workspace_path).await?;
1150
1151        let filtered_articles: Vec<_> = result
1152            .articles
1153            .into_iter()
1154            .filter(|article| article.domain.as_deref() == Some(domain))
1155            .collect();
1156
1157        Ok(KnowledgeLoadResult {
1158            articles: filtered_articles,
1159            errors: result.errors,
1160        })
1161    }
1162
1163    /// Load decisions by domain
1164    ///
1165    /// Filters decisions by their domain field.
1166    ///
1167    /// # Arguments
1168    ///
1169    /// * `workspace_path` - Path to the workspace directory
1170    /// * `domain` - Domain name to filter by
1171    ///
1172    /// # Returns
1173    ///
1174    /// A DecisionLoadResult containing matching decisions and any errors encountered
1175    pub async fn load_decisions_by_domain(
1176        &self,
1177        workspace_path: &str,
1178        domain: &str,
1179    ) -> Result<DecisionLoadResult, StorageError> {
1180        let result = self.load_decisions(workspace_path).await?;
1181
1182        let filtered_decisions: Vec<_> = result
1183            .decisions
1184            .into_iter()
1185            .filter(|decision| decision.domain.as_deref() == Some(domain))
1186            .collect();
1187
1188        Ok(DecisionLoadResult {
1189            decisions: filtered_decisions,
1190            errors: result.errors,
1191        })
1192    }
1193
1194    // ==================== Workspace and Domain Config Loading ====================
1195
1196    /// Load workspace configuration from workspace.yaml
1197    ///
1198    /// # Arguments
1199    ///
1200    /// * `workspace_path` - Path to the workspace directory
1201    ///
1202    /// # Returns
1203    ///
1204    /// The Workspace configuration if found, or None if workspace.yaml doesn't exist
1205    pub async fn load_workspace(
1206        &self,
1207        workspace_path: &str,
1208    ) -> Result<Option<Workspace>, StorageError> {
1209        let workspace_file = format!("{}/workspace.yaml", workspace_path);
1210
1211        if !self.storage.file_exists(&workspace_file).await? {
1212            return Ok(None);
1213        }
1214
1215        let content = self.storage.read_file(&workspace_file).await?;
1216        let yaml_content = String::from_utf8(content)
1217            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
1218
1219        let workspace: Workspace = serde_yaml::from_str(&yaml_content).map_err(|e| {
1220            StorageError::SerializationError(format!("Failed to parse workspace.yaml: {}", e))
1221        })?;
1222
1223        Ok(Some(workspace))
1224    }
1225
1226    /// Save workspace configuration to workspace.yaml
1227    ///
1228    /// # Arguments
1229    ///
1230    /// * `workspace_path` - Path to the workspace directory
1231    /// * `workspace` - The Workspace configuration to save
1232    pub async fn save_workspace(
1233        &self,
1234        workspace_path: &str,
1235        workspace: &Workspace,
1236    ) -> Result<(), StorageError> {
1237        let workspace_file = format!("{}/workspace.yaml", workspace_path);
1238
1239        let yaml_content = serde_yaml::to_string(workspace).map_err(|e| {
1240            StorageError::SerializationError(format!("Failed to serialize workspace: {}", e))
1241        })?;
1242
1243        self.storage
1244            .write_file(&workspace_file, yaml_content.as_bytes())
1245            .await?;
1246
1247        Ok(())
1248    }
1249
1250    /// Load domain configuration from domain.yaml
1251    ///
1252    /// # Arguments
1253    ///
1254    /// * `domain_dir` - Path to the domain directory
1255    ///
1256    /// # Returns
1257    ///
1258    /// The DomainConfig if found, or None if domain.yaml doesn't exist
1259    pub async fn load_domain_config(
1260        &self,
1261        domain_dir: &str,
1262    ) -> Result<Option<DomainConfig>, StorageError> {
1263        let domain_file = format!("{}/domain.yaml", domain_dir);
1264
1265        if !self.storage.file_exists(&domain_file).await? {
1266            return Ok(None);
1267        }
1268
1269        let content = self.storage.read_file(&domain_file).await?;
1270        let yaml_content = String::from_utf8(content)
1271            .map_err(|e| StorageError::SerializationError(format!("Invalid UTF-8: {}", e)))?;
1272
1273        let config: DomainConfig = serde_yaml::from_str(&yaml_content).map_err(|e| {
1274            StorageError::SerializationError(format!("Failed to parse domain.yaml: {}", e))
1275        })?;
1276
1277        Ok(Some(config))
1278    }
1279
1280    /// Save domain configuration to domain.yaml
1281    ///
1282    /// # Arguments
1283    ///
1284    /// * `domain_dir` - Path to the domain directory
1285    /// * `config` - The DomainConfig to save
1286    pub async fn save_domain_config(
1287        &self,
1288        domain_dir: &str,
1289        config: &DomainConfig,
1290    ) -> Result<(), StorageError> {
1291        let domain_file = format!("{}/domain.yaml", domain_dir);
1292
1293        let yaml_content = serde_yaml::to_string(config).map_err(|e| {
1294            StorageError::SerializationError(format!("Failed to serialize domain config: {}", e))
1295        })?;
1296
1297        self.storage
1298            .write_file(&domain_file, yaml_content.as_bytes())
1299            .await?;
1300
1301        Ok(())
1302    }
1303
1304    /// Load domain configuration by name from a workspace
1305    ///
1306    /// # Arguments
1307    ///
1308    /// * `workspace_path` - Path to the workspace directory
1309    /// * `domain_name` - Name of the domain (folder name)
1310    ///
1311    /// # Returns
1312    ///
1313    /// The DomainConfig if found
1314    pub async fn load_domain_config_by_name(
1315        &self,
1316        workspace_path: &str,
1317        domain_name: &str,
1318    ) -> Result<Option<DomainConfig>, StorageError> {
1319        let sanitized_domain_name = sanitize_filename(domain_name);
1320        let domain_dir = format!("{}/{}", workspace_path, sanitized_domain_name);
1321        self.load_domain_config(&domain_dir).await
1322    }
1323
1324    /// Get domain ID from domain.yaml, or None if not found
1325    ///
1326    /// Get domain ID from domain.yaml (DEPRECATED)
1327    ///
1328    /// This method is deprecated. Domain information is now stored in workspace.yaml.
1329    #[deprecated(
1330        since = "2.0.0",
1331        note = "Domain directories are no longer supported. Domain info is in workspace.yaml"
1332    )]
1333    #[allow(dead_code)]
1334    pub async fn get_domain_id(&self, domain_dir: &str) -> Result<Option<Uuid>, StorageError> {
1335        match self.load_domain_config(domain_dir).await? {
1336            Some(config) => Ok(Some(config.id)),
1337            None => Ok(None),
1338        }
1339    }
1340
1341    /// Load all domain configurations from a workspace (DEPRECATED)
1342    ///
1343    /// This method is deprecated. Use load_workspace() and access domains from the workspace.
1344    #[deprecated(
1345        since = "2.0.0",
1346        note = "Domain directories are no longer supported. Use load_workspace() instead"
1347    )]
1348    #[allow(dead_code)]
1349    pub async fn load_all_domain_configs(
1350        &self,
1351        workspace_path: &str,
1352    ) -> Result<Vec<DomainConfig>, StorageError> {
1353        warn!(
1354            "load_all_domain_configs is deprecated. Use load_workspace() for workspace: {}",
1355            workspace_path
1356        );
1357
1358        // Return empty as domain directories are no longer supported
1359        Ok(Vec::new())
1360    }
1361}
1362
1363/// Sanitize a filename by removing invalid characters
1364fn sanitize_filename(name: &str) -> String {
1365    name.chars()
1366        .map(|c| match c {
1367            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1368            _ => c,
1369        })
1370        .collect()
1371}
1372
1373/// Result of loading a model
1374#[derive(Debug, Serialize, Deserialize)]
1375pub struct ModelLoadResult {
1376    pub tables: Vec<TableData>,
1377    pub relationships: Vec<RelationshipData>,
1378    pub orphaned_relationships: Vec<RelationshipData>,
1379}
1380
1381/// Table data loaded from storage
1382#[derive(Debug, Clone, Serialize, Deserialize)]
1383pub struct TableData {
1384    pub id: Uuid,
1385    pub name: String,
1386    pub yaml_file_path: Option<String>,
1387    pub yaml_content: String,
1388}
1389
1390/// Relationship data loaded from storage
1391#[derive(Debug, Clone, Serialize, Deserialize)]
1392pub struct RelationshipData {
1393    pub id: Uuid,
1394    pub source_table_id: Uuid,
1395    pub target_table_id: Uuid,
1396}
1397
1398/// Result of loading domains
1399#[derive(Debug)]
1400pub struct DomainLoadResult {
1401    pub domains: Vec<Domain>,
1402    pub tables: HashMap<Uuid, Table>,
1403    pub odps_products: HashMap<Uuid, ODPSDataProduct>,
1404    pub cads_assets: HashMap<Uuid, CADSAsset>,
1405}
1406
1407/// Result of loading decisions
1408#[derive(Debug)]
1409pub struct DecisionLoadResult {
1410    /// Successfully loaded decisions
1411    pub decisions: Vec<Decision>,
1412    /// Errors encountered during loading
1413    pub errors: Vec<DecisionLoadError>,
1414}
1415
1416/// Error encountered while loading a decision
1417#[derive(Debug, Clone)]
1418pub struct DecisionLoadError {
1419    /// Path to the file that failed to load
1420    pub file_path: String,
1421    /// Error message
1422    pub error: String,
1423}
1424
1425/// Result of loading knowledge articles
1426#[derive(Debug)]
1427pub struct KnowledgeLoadResult {
1428    /// Successfully loaded knowledge articles
1429    pub articles: Vec<KnowledgeArticle>,
1430    /// Errors encountered during loading
1431    pub errors: Vec<KnowledgeLoadError>,
1432}
1433
1434/// Error encountered while loading a knowledge article
1435#[derive(Debug, Clone)]
1436pub struct KnowledgeLoadError {
1437    /// Path to the file that failed to load
1438    pub file_path: String,
1439    /// Error message
1440    pub error: String,
1441}