Skip to main content

data_modelling_core/models/
workspace.rs

1//! Workspace model
2//!
3//! Defines the Workspace entity for the data modelling application.
4//! Workspaces are top-level containers that organize domains and their associated assets.
5//!
6//! ## File Naming Convention
7//!
8//! All files use a flat naming pattern:
9//! - `workspace.yaml` - workspace metadata with references to all assets and relationships
10//! - `{workspace}_{domain}_{system}_{resource}.odcs.yaml` - ODCS table files
11//! - `{workspace}_{domain}_{system}_{resource}.odps.yaml` - ODPS product files
12//! - `{workspace}_{domain}_{system}_{resource}.cads.yaml` - CADS asset files
13//!
14//! Where `{system}` is optional if the resource is at the domain level.
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use uuid::Uuid;
20
21use super::Relationship;
22use super::domain_config::ViewPosition;
23use super::enums::{AuthMethod, EnvironmentStatus, InfrastructureType};
24use super::table::{ContactDetails, SlaProperty};
25
26/// Asset reference within a workspace
27///
28/// Contains information about an asset file and its location in the domain/system hierarchy.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30#[serde(rename_all = "camelCase")]
31pub struct AssetReference {
32    /// Asset identifier (UUID)
33    pub id: Uuid,
34    /// Asset name
35    pub name: String,
36    /// Domain name this asset belongs to
37    pub domain: String,
38    /// Optional system name (if asset is within a system)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub system: Option<String>,
41    /// Asset type (odcs, odps, cads)
42    #[serde(alias = "asset_type")]
43    pub asset_type: AssetType,
44    /// File path relative to workspace (generated from naming convention)
45    #[serde(skip_serializing_if = "Option::is_none", alias = "file_path")]
46    pub file_path: Option<String>,
47}
48
49/// Type of asset or file in the workspace
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51#[serde(rename_all = "lowercase")]
52pub enum AssetType {
53    /// Workspace configuration file
54    Workspace,
55    /// Relationships file
56    Relationships,
57    /// ODCS table definition
58    Odcs,
59    /// ODPS data product
60    Odps,
61    /// CADS compute asset
62    Cads,
63    /// BPMN process model
64    Bpmn,
65    /// DMN decision model
66    Dmn,
67    /// OpenAPI specification
68    Openapi,
69    /// MADR decision record
70    Decision,
71    /// Knowledge base article
72    Knowledge,
73    /// Decision log index file
74    DecisionIndex,
75    /// Knowledge base index file
76    KnowledgeIndex,
77    /// Excalidraw sketch
78    Sketch,
79    /// Sketch index file
80    SketchIndex,
81    /// Databricks Metric Views
82    Dbmv,
83}
84
85impl AssetType {
86    /// Get file extension for this asset type
87    pub fn extension(&self) -> &'static str {
88        match self {
89            AssetType::Workspace => "yaml",
90            AssetType::Relationships => "yaml",
91            AssetType::Odcs => "odcs.yaml",
92            AssetType::Odps => "odps.yaml",
93            AssetType::Cads => "cads.yaml",
94            AssetType::Bpmn => "bpmn.xml",
95            AssetType::Dmn => "dmn.xml",
96            AssetType::Openapi => "openapi.yaml",
97            AssetType::Decision => "madr.yaml",
98            AssetType::Knowledge => "kb.yaml",
99            AssetType::DecisionIndex => "yaml",
100            AssetType::KnowledgeIndex => "yaml",
101            AssetType::Sketch => "sketch.yaml",
102            AssetType::SketchIndex => "yaml",
103            AssetType::Dbmv => "dbmv.yaml",
104        }
105    }
106
107    /// Get the full filename for workspace-level files
108    pub fn filename(&self) -> Option<&'static str> {
109        match self {
110            AssetType::Workspace => Some("workspace.yaml"),
111            AssetType::Relationships => Some("relationships.yaml"),
112            AssetType::DecisionIndex => Some("decisions.yaml"),
113            AssetType::KnowledgeIndex => Some("knowledge.yaml"),
114            AssetType::SketchIndex => Some("sketches.yaml"),
115            _ => None,
116        }
117    }
118
119    /// Check if this is a workspace-level file (not a domain/system asset)
120    pub fn is_workspace_level(&self) -> bool {
121        matches!(
122            self,
123            AssetType::Workspace
124                | AssetType::Relationships
125                | AssetType::DecisionIndex
126                | AssetType::KnowledgeIndex
127                | AssetType::SketchIndex
128        )
129    }
130
131    /// Detect asset type from filename
132    pub fn from_filename(filename: &str) -> Option<Self> {
133        if filename == "workspace.yaml" {
134            Some(AssetType::Workspace)
135        } else if filename == "relationships.yaml" {
136            Some(AssetType::Relationships)
137        } else if filename == "decisions.yaml" {
138            Some(AssetType::DecisionIndex)
139        } else if filename == "knowledge.yaml" {
140            Some(AssetType::KnowledgeIndex)
141        } else if filename == "sketches.yaml" {
142            Some(AssetType::SketchIndex)
143        } else if filename.ends_with(".odcs.yaml") {
144            Some(AssetType::Odcs)
145        } else if filename.ends_with(".odps.yaml") {
146            Some(AssetType::Odps)
147        } else if filename.ends_with(".cads.yaml") {
148            Some(AssetType::Cads)
149        } else if filename.ends_with(".madr.yaml") {
150            Some(AssetType::Decision)
151        } else if filename.ends_with(".kb.yaml") {
152            Some(AssetType::Knowledge)
153        } else if filename.ends_with(".sketch.yaml") {
154            Some(AssetType::Sketch)
155        } else if filename.ends_with(".dbmv.yaml") {
156            Some(AssetType::Dbmv)
157        } else if filename.ends_with(".bpmn.xml") {
158            Some(AssetType::Bpmn)
159        } else if filename.ends_with(".dmn.xml") {
160            Some(AssetType::Dmn)
161        } else if filename.ends_with(".openapi.yaml") || filename.ends_with(".openapi.json") {
162            Some(AssetType::Openapi)
163        } else {
164            None
165        }
166    }
167
168    /// Get all supported file extensions
169    pub fn supported_extensions() -> &'static [&'static str] {
170        &[
171            "workspace.yaml",
172            "relationships.yaml",
173            "decisions.yaml",
174            "knowledge.yaml",
175            "sketches.yaml",
176            ".odcs.yaml",
177            ".odps.yaml",
178            ".cads.yaml",
179            ".madr.yaml",
180            ".kb.yaml",
181            ".sketch.yaml",
182            ".dbmv.yaml",
183            ".bpmn.xml",
184            ".dmn.xml",
185            ".openapi.yaml",
186            ".openapi.json",
187        ]
188    }
189
190    /// Check if a filename is a supported asset type
191    pub fn is_supported_file(filename: &str) -> bool {
192        Self::from_filename(filename).is_some()
193    }
194}
195
196/// Visibility setting for tables within a domain
197#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
198#[serde(rename_all = "camelCase")]
199pub enum TableVisibility {
200    /// Tables are visible to all users
201    #[default]
202    Public,
203    /// Tables are visible only within the domain
204    DomainOnly,
205    /// Tables are hidden by default
206    Hidden,
207}
208
209/// Link to a transformation or data pipeline
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
211#[serde(rename_all = "camelCase")]
212pub struct TransformationLink {
213    /// Unique identifier for the transformation
214    pub id: Uuid,
215    /// Name of the transformation
216    pub name: String,
217    /// Type of transformation (e.g., "dbt", "spark", "airflow")
218    #[serde(skip_serializing_if = "Option::is_none", alias = "transformation_type")]
219    pub transformation_type: Option<String>,
220    /// URL or path to the transformation definition
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub url: Option<String>,
223    /// Optional description
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub description: Option<String>,
226}
227
228/// Shared resource within a domain (e.g., shared schemas, libraries, utilities)
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230#[serde(rename_all = "camelCase")]
231pub struct SharedResource {
232    /// Unique identifier for the resource
233    pub id: Uuid,
234    /// Name of the shared resource
235    pub name: String,
236    /// Type of resource (e.g., "schema", "library", "utility", "template")
237    #[serde(alias = "resource_type")]
238    pub resource_type: String,
239    /// URL or path to the resource
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub url: Option<String>,
242    /// Optional description
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub description: Option<String>,
245}
246
247/// Domain reference within a workspace
248///
249/// Contains information about a domain and its systems.
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
251#[serde(rename_all = "camelCase")]
252pub struct DomainReference {
253    /// Domain identifier
254    pub id: Uuid,
255    /// Domain name
256    pub name: String,
257    /// Optional description
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub description: Option<String>,
260    /// Systems within this domain
261    #[serde(default, skip_serializing_if = "Vec::is_empty")]
262    pub systems: Vec<SystemReference>,
263    /// Shared resources available across the domain (schemas, libraries, utilities)
264    #[serde(
265        default,
266        skip_serializing_if = "Vec::is_empty",
267        alias = "shared_resources"
268    )]
269    pub shared_resources: Vec<SharedResource>,
270    /// Links to transformations and data pipelines associated with this domain
271    #[serde(
272        default,
273        skip_serializing_if = "Vec::is_empty",
274        alias = "transformation_links"
275    )]
276    pub transformation_links: Vec<TransformationLink>,
277    /// Default visibility setting for tables within this domain
278    #[serde(
279        default,
280        skip_serializing_if = "Option::is_none",
281        alias = "table_visibility"
282    )]
283    pub table_visibility: Option<TableVisibility>,
284    /// View positions for different view modes (operational, analytical, process, systems)
285    /// Key: view mode name, Value: Map of entity ID to position
286    #[serde(
287        default,
288        skip_serializing_if = "HashMap::is_empty",
289        alias = "view_positions"
290    )]
291    pub view_positions: HashMap<String, HashMap<String, ViewPosition>>,
292}
293
294/// Environment-specific connection details for a system
295///
296/// Systems may have multiple environments (production, staging, development, etc.)
297/// each with different connection details, SLAs, ownership, and authentication methods.
298/// This allows tracking environment-specific configuration while keeping the system
299/// definition unified.
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301#[serde(rename_all = "camelCase")]
302pub struct EnvironmentConnection {
303    /// Environment name (e.g., "production", "staging", "development")
304    pub environment: String,
305
306    /// Owner/team responsible for this environment
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub owner: Option<String>,
309
310    /// Contact details for this environment
311    #[serde(skip_serializing_if = "Option::is_none", alias = "contact_details")]
312    pub contact_details: Option<ContactDetails>,
313
314    /// SLA properties for this environment
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub sla: Option<Vec<SlaProperty>>,
317
318    /// Authentication method for connecting to this environment
319    #[serde(skip_serializing_if = "Option::is_none", alias = "auth_method")]
320    pub auth_method: Option<AuthMethod>,
321
322    /// Support team or on-call rotation name
323    #[serde(skip_serializing_if = "Option::is_none", alias = "support_team")]
324    pub support_team: Option<String>,
325
326    /// Connection string (sensitive - may be placeholder or reference to secrets manager)
327    #[serde(skip_serializing_if = "Option::is_none", alias = "connection_string")]
328    pub connection_string: Option<String>,
329
330    /// Link to secrets manager entry (e.g., AWS Secrets Manager, HashiCorp Vault)
331    #[serde(skip_serializing_if = "Option::is_none", alias = "secret_link")]
332    pub secret_link: Option<String>,
333
334    /// Primary endpoint URL or hostname
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub endpoint: Option<String>,
337
338    /// Port number for the connection
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub port: Option<u16>,
341
342    /// Cloud region or data center location
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub region: Option<String>,
345
346    /// Current status of this environment
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub status: Option<EnvironmentStatus>,
349
350    /// Additional notes about this environment
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub notes: Option<String>,
353
354    /// Additional custom properties for extensibility
355    #[serde(
356        default,
357        skip_serializing_if = "HashMap::is_empty",
358        alias = "custom_properties"
359    )]
360    pub custom_properties: HashMap<String, serde_json::Value>,
361}
362
363/// System reference within a domain
364///
365/// Systems represent infrastructure components like databases, message queues,
366/// or cloud services that contain tables and assets. Systems can have multiple
367/// environment-specific connection details for production, staging, etc.
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
369#[serde(rename_all = "camelCase")]
370pub struct SystemReference {
371    /// System identifier
372    pub id: Uuid,
373    /// System name
374    pub name: String,
375    /// Optional description
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub description: Option<String>,
378    /// System type (infrastructure type) - optional for backward compatibility
379    #[serde(skip_serializing_if = "Option::is_none", alias = "system_type")]
380    pub system_type: Option<InfrastructureType>,
381    /// Optional array of table UUIDs that belong to this system.
382    /// When present, provides explicit table-to-system mapping without requiring parsing of individual ODCS files.
383    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "table_ids")]
384    pub table_ids: Vec<Uuid>,
385    /// Optional array of compute asset (CADS) UUIDs that belong to this system.
386    /// When present, provides explicit asset-to-system mapping.
387    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "asset_ids")]
388    pub asset_ids: Vec<Uuid>,
389    /// Environment-specific connection details (production, staging, development, etc.)
390    #[serde(default, skip_serializing_if = "Vec::is_empty")]
391    pub environments: Vec<EnvironmentConnection>,
392}
393
394/// Workspace - Top-level container for domains, assets, and relationships
395///
396/// Workspaces organize domains, systems, and their associated assets.
397/// All files use a flat naming convention: `{workspace}_{domain}_{system}_{resource}.xxx.yaml`
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
399#[serde(rename_all = "camelCase")]
400pub struct Workspace {
401    /// Unique identifier for the workspace
402    pub id: Uuid,
403    /// Workspace name (used in file naming)
404    pub name: String,
405    /// Owner/creator user identifier
406    #[serde(alias = "owner_id")]
407    pub owner_id: Uuid,
408    /// Creation timestamp
409    #[serde(alias = "created_at")]
410    pub created_at: DateTime<Utc>,
411    /// Last modification timestamp
412    #[serde(alias = "last_modified_at")]
413    pub last_modified_at: DateTime<Utc>,
414    /// Optional workspace description (displayed in UI and README)
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub description: Option<String>,
417    /// Domain references with their systems
418    #[serde(default)]
419    pub domains: Vec<DomainReference>,
420    /// All asset references in this workspace
421    #[serde(default)]
422    pub assets: Vec<AssetReference>,
423    /// Relationships between assets in this workspace
424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
425    pub relationships: Vec<Relationship>,
426}
427
428impl Workspace {
429    /// Create a new Workspace
430    pub fn new(name: String, owner_id: Uuid) -> Self {
431        let now = Utc::now();
432        Self {
433            id: Uuid::new_v4(),
434            name,
435            owner_id,
436            created_at: now,
437            last_modified_at: now,
438            description: None,
439            domains: Vec::new(),
440            assets: Vec::new(),
441            relationships: Vec::new(),
442        }
443    }
444
445    /// Create a workspace with a specific ID
446    pub fn with_id(id: Uuid, name: String, owner_id: Uuid) -> Self {
447        let now = Utc::now();
448        Self {
449            id,
450            name,
451            owner_id,
452            created_at: now,
453            last_modified_at: now,
454            description: None,
455            domains: Vec::new(),
456            assets: Vec::new(),
457            relationships: Vec::new(),
458        }
459    }
460
461    /// Add a relationship to the workspace
462    pub fn add_relationship(&mut self, relationship: Relationship) {
463        // Check if relationship already exists
464        if self.relationships.iter().any(|r| r.id == relationship.id) {
465            return;
466        }
467        self.relationships.push(relationship);
468        self.last_modified_at = Utc::now();
469    }
470
471    /// Remove a relationship by ID
472    pub fn remove_relationship(&mut self, relationship_id: Uuid) -> bool {
473        let initial_len = self.relationships.len();
474        self.relationships.retain(|r| r.id != relationship_id);
475        let removed = self.relationships.len() < initial_len;
476        if removed {
477            self.last_modified_at = Utc::now();
478        }
479        removed
480    }
481
482    /// Get relationships by source table ID
483    pub fn get_relationships_for_source(&self, source_table_id: Uuid) -> Vec<&Relationship> {
484        self.relationships
485            .iter()
486            .filter(|r| r.source_table_id == source_table_id)
487            .collect()
488    }
489
490    /// Get relationships by target table ID
491    pub fn get_relationships_for_target(&self, target_table_id: Uuid) -> Vec<&Relationship> {
492        self.relationships
493            .iter()
494            .filter(|r| r.target_table_id == target_table_id)
495            .collect()
496    }
497
498    /// Add a domain reference to the workspace
499    pub fn add_domain(&mut self, domain_id: Uuid, domain_name: String) {
500        // Check if domain already exists
501        if self.domains.iter().any(|d| d.id == domain_id) {
502            return;
503        }
504        self.domains.push(DomainReference {
505            id: domain_id,
506            name: domain_name,
507            description: None,
508            systems: Vec::new(),
509            shared_resources: Vec::new(),
510            transformation_links: Vec::new(),
511            table_visibility: None,
512            view_positions: HashMap::new(),
513        });
514        self.last_modified_at = Utc::now();
515    }
516
517    /// Add a domain with description
518    pub fn add_domain_with_description(
519        &mut self,
520        domain_id: Uuid,
521        domain_name: String,
522        description: Option<String>,
523    ) {
524        if self.domains.iter().any(|d| d.id == domain_id) {
525            return;
526        }
527        self.domains.push(DomainReference {
528            id: domain_id,
529            name: domain_name,
530            description,
531            systems: Vec::new(),
532            shared_resources: Vec::new(),
533            transformation_links: Vec::new(),
534            table_visibility: None,
535            view_positions: HashMap::new(),
536        });
537        self.last_modified_at = Utc::now();
538    }
539
540    /// Add a system to a domain
541    pub fn add_system_to_domain(
542        &mut self,
543        domain_name: &str,
544        system_id: Uuid,
545        system_name: String,
546        description: Option<String>,
547    ) -> bool {
548        if let Some(domain) = self.domains.iter_mut().find(|d| d.name == domain_name)
549            && !domain.systems.iter().any(|s| s.id == system_id)
550        {
551            domain.systems.push(SystemReference {
552                id: system_id,
553                name: system_name,
554                description,
555                system_type: None,
556                table_ids: Vec::new(),
557                asset_ids: Vec::new(),
558                environments: Vec::new(),
559            });
560            self.last_modified_at = Utc::now();
561            return true;
562        }
563        false
564    }
565
566    /// Remove a domain reference by ID
567    pub fn remove_domain(&mut self, domain_id: Uuid) -> bool {
568        let initial_len = self.domains.len();
569        self.domains.retain(|d| d.id != domain_id);
570        // Also remove assets belonging to this domain
571        if let Some(domain) = self.domains.iter().find(|d| d.id == domain_id) {
572            let domain_name = domain.name.clone();
573            self.assets.retain(|a| a.domain != domain_name);
574        }
575        if self.domains.len() != initial_len {
576            self.last_modified_at = Utc::now();
577            true
578        } else {
579            false
580        }
581    }
582
583    /// Get a domain reference by ID
584    pub fn get_domain(&self, domain_id: Uuid) -> Option<&DomainReference> {
585        self.domains.iter().find(|d| d.id == domain_id)
586    }
587
588    /// Get a domain reference by name
589    pub fn get_domain_by_name(&self, name: &str) -> Option<&DomainReference> {
590        self.domains.iter().find(|d| d.name == name)
591    }
592
593    /// Add an asset reference
594    pub fn add_asset(&mut self, asset: AssetReference) {
595        // Check if asset already exists
596        if self.assets.iter().any(|a| a.id == asset.id) {
597            return;
598        }
599        self.assets.push(asset);
600        self.last_modified_at = Utc::now();
601    }
602
603    /// Remove an asset by ID
604    pub fn remove_asset(&mut self, asset_id: Uuid) -> bool {
605        let initial_len = self.assets.len();
606        self.assets.retain(|a| a.id != asset_id);
607        if self.assets.len() != initial_len {
608            self.last_modified_at = Utc::now();
609            true
610        } else {
611            false
612        }
613    }
614
615    /// Get an asset by ID
616    pub fn get_asset(&self, asset_id: Uuid) -> Option<&AssetReference> {
617        self.assets.iter().find(|a| a.id == asset_id)
618    }
619
620    /// Get assets by domain
621    pub fn get_assets_by_domain(&self, domain_name: &str) -> Vec<&AssetReference> {
622        self.assets
623            .iter()
624            .filter(|a| a.domain == domain_name)
625            .collect()
626    }
627
628    /// Get assets by type
629    pub fn get_assets_by_type(&self, asset_type: &AssetType) -> Vec<&AssetReference> {
630        self.assets
631            .iter()
632            .filter(|a| &a.asset_type == asset_type)
633            .collect()
634    }
635
636    /// Generate filename for an asset using the naming convention
637    /// Format: {workspace}_{domain}_{system}_{resource}.{extension}
638    pub fn generate_asset_filename(&self, asset: &AssetReference) -> String {
639        let mut parts = vec![sanitize_name(&self.name), sanitize_name(&asset.domain)];
640
641        if let Some(ref system) = asset.system {
642            parts.push(sanitize_name(system));
643        }
644
645        parts.push(sanitize_name(&asset.name));
646
647        format!("{}.{}", parts.join("_"), asset.asset_type.extension())
648    }
649
650    /// Parse a filename to extract workspace, domain, system, and resource names
651    /// Returns (domain, system, resource_name) or None if parsing fails
652    pub fn parse_asset_filename(
653        filename: &str,
654    ) -> Option<(String, Option<String>, String, AssetType)> {
655        // Determine asset type from extension
656        let (base, asset_type) = if filename.ends_with(".odcs.yaml") {
657            (filename.strip_suffix(".odcs.yaml")?, AssetType::Odcs)
658        } else if filename.ends_with(".odps.yaml") {
659            (filename.strip_suffix(".odps.yaml")?, AssetType::Odps)
660        } else if filename.ends_with(".cads.yaml") {
661            (filename.strip_suffix(".cads.yaml")?, AssetType::Cads)
662        } else if filename.ends_with(".bpmn.xml") {
663            (filename.strip_suffix(".bpmn.xml")?, AssetType::Bpmn)
664        } else if filename.ends_with(".dmn.xml") {
665            (filename.strip_suffix(".dmn.xml")?, AssetType::Dmn)
666        } else if filename.ends_with(".openapi.yaml") {
667            (filename.strip_suffix(".openapi.yaml")?, AssetType::Openapi)
668        } else if filename.ends_with(".dbmv.yaml") {
669            (filename.strip_suffix(".dbmv.yaml")?, AssetType::Dbmv)
670        } else {
671            return None;
672        };
673
674        let parts: Vec<&str> = base.split('_').collect();
675
676        match parts.len() {
677            // workspace_domain_resource (no system)
678            3 => Some((parts[1].to_string(), None, parts[2].to_string(), asset_type)),
679            // workspace_domain_system_resource
680            4 => Some((
681                parts[1].to_string(),
682                Some(parts[2].to_string()),
683                parts[3].to_string(),
684                asset_type,
685            )),
686            // More than 4 parts - treat remaining as resource name with underscores
687            n if n > 4 => Some((
688                parts[1].to_string(),
689                Some(parts[2].to_string()),
690                parts[3..].join("_"),
691                asset_type,
692            )),
693            _ => None,
694        }
695    }
696
697    /// Import workspace from YAML
698    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
699        serde_yaml::from_str(yaml_content)
700    }
701
702    /// Export workspace to YAML
703    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
704        serde_yaml::to_string(self)
705    }
706
707    /// Import workspace from JSON
708    pub fn from_json(json_content: &str) -> Result<Self, serde_json::Error> {
709        serde_json::from_str(json_content)
710    }
711
712    /// Export workspace to JSON
713    pub fn to_json(&self) -> Result<String, serde_json::Error> {
714        serde_json::to_string(self)
715    }
716
717    /// Export workspace to pretty JSON
718    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
719        serde_json::to_string_pretty(self)
720    }
721}
722
723/// Sanitize a name for use in filenames (replace spaces/special chars with hyphens)
724fn sanitize_name(name: &str) -> String {
725    name.chars()
726        .map(|c| match c {
727            ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
728            _ => c,
729        })
730        .collect::<String>()
731        .to_lowercase()
732}
733
734impl Default for Workspace {
735    fn default() -> Self {
736        Self::new("Default Workspace".to_string(), Uuid::new_v4())
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn test_workspace_new() {
746        let owner_id = Uuid::new_v4();
747        let workspace = Workspace::new("Test Workspace".to_string(), owner_id);
748
749        assert_eq!(workspace.name, "Test Workspace");
750        assert_eq!(workspace.owner_id, owner_id);
751        assert!(workspace.domains.is_empty());
752        assert!(workspace.assets.is_empty());
753    }
754
755    #[test]
756    fn test_workspace_add_domain() {
757        let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
758        let domain_id = Uuid::new_v4();
759
760        workspace.add_domain(domain_id, "customer-management".to_string());
761
762        assert_eq!(workspace.domains.len(), 1);
763        assert_eq!(workspace.domains[0].id, domain_id);
764        assert_eq!(workspace.domains[0].name, "customer-management");
765
766        // Adding same domain again should not duplicate
767        workspace.add_domain(domain_id, "customer-management".to_string());
768        assert_eq!(workspace.domains.len(), 1);
769    }
770
771    #[test]
772    fn test_workspace_add_system_to_domain() {
773        let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
774        let domain_id = Uuid::new_v4();
775        let system_id = Uuid::new_v4();
776
777        workspace.add_domain(domain_id, "sales".to_string());
778        let result = workspace.add_system_to_domain(
779            "sales",
780            system_id,
781            "kafka".to_string(),
782            Some("Kafka streaming".to_string()),
783        );
784
785        assert!(result);
786        assert_eq!(workspace.domains[0].systems.len(), 1);
787        assert_eq!(workspace.domains[0].systems[0].name, "kafka");
788    }
789
790    #[test]
791    fn test_workspace_remove_domain() {
792        let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
793        let domain_id = Uuid::new_v4();
794        workspace.add_domain(domain_id, "test-domain".to_string());
795
796        assert!(workspace.remove_domain(domain_id));
797        assert!(workspace.domains.is_empty());
798        assert!(!workspace.remove_domain(domain_id)); // Already removed
799    }
800
801    #[test]
802    fn test_workspace_add_asset() {
803        let mut workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
804        let asset_id = Uuid::new_v4();
805
806        let asset = AssetReference {
807            id: asset_id,
808            name: "orders".to_string(),
809            domain: "sales".to_string(),
810            system: Some("kafka".to_string()),
811            asset_type: AssetType::Odcs,
812            file_path: None,
813        };
814
815        workspace.add_asset(asset);
816        assert_eq!(workspace.assets.len(), 1);
817        assert_eq!(workspace.assets[0].name, "orders");
818    }
819
820    #[test]
821    fn test_workspace_generate_asset_filename() {
822        let workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
823
824        // With system
825        let asset_with_system = AssetReference {
826            id: Uuid::new_v4(),
827            name: "orders".to_string(),
828            domain: "sales".to_string(),
829            system: Some("kafka".to_string()),
830            asset_type: AssetType::Odcs,
831            file_path: None,
832        };
833        assert_eq!(
834            workspace.generate_asset_filename(&asset_with_system),
835            "enterprise_sales_kafka_orders.odcs.yaml"
836        );
837
838        // Without system
839        let asset_no_system = AssetReference {
840            id: Uuid::new_v4(),
841            name: "customers".to_string(),
842            domain: "crm".to_string(),
843            system: None,
844            asset_type: AssetType::Odcs,
845            file_path: None,
846        };
847        assert_eq!(
848            workspace.generate_asset_filename(&asset_no_system),
849            "enterprise_crm_customers.odcs.yaml"
850        );
851
852        // ODPS product
853        let odps_asset = AssetReference {
854            id: Uuid::new_v4(),
855            name: "analytics".to_string(),
856            domain: "finance".to_string(),
857            system: None,
858            asset_type: AssetType::Odps,
859            file_path: None,
860        };
861        assert_eq!(
862            workspace.generate_asset_filename(&odps_asset),
863            "enterprise_finance_analytics.odps.yaml"
864        );
865    }
866
867    #[test]
868    fn test_workspace_parse_asset_filename() {
869        // With system
870        let result = Workspace::parse_asset_filename("enterprise_sales_kafka_orders.odcs.yaml");
871        assert!(result.is_some());
872        let (domain, system, name, asset_type) = result.unwrap();
873        assert_eq!(domain, "sales");
874        assert_eq!(system, Some("kafka".to_string()));
875        assert_eq!(name, "orders");
876        assert_eq!(asset_type, AssetType::Odcs);
877
878        // Without system (3 parts)
879        let result = Workspace::parse_asset_filename("enterprise_crm_customers.odcs.yaml");
880        assert!(result.is_some());
881        let (domain, system, name, asset_type) = result.unwrap();
882        assert_eq!(domain, "crm");
883        assert_eq!(system, None);
884        assert_eq!(name, "customers");
885        assert_eq!(asset_type, AssetType::Odcs);
886
887        // ODPS type
888        let result = Workspace::parse_asset_filename("workspace_domain_product.odps.yaml");
889        assert!(result.is_some());
890        let (_, _, _, asset_type) = result.unwrap();
891        assert_eq!(asset_type, AssetType::Odps);
892
893        // DBMV type
894        let result =
895            Workspace::parse_asset_filename("enterprise_sales_databricks_metrics.dbmv.yaml");
896        assert!(result.is_some());
897        let (domain, system, name, asset_type) = result.unwrap();
898        assert_eq!(domain, "sales");
899        assert_eq!(system, Some("databricks".to_string()));
900        assert_eq!(name, "metrics");
901        assert_eq!(asset_type, AssetType::Dbmv);
902    }
903
904    #[test]
905    fn test_workspace_yaml_roundtrip() {
906        let mut workspace = Workspace::new("Enterprise Models".to_string(), Uuid::new_v4());
907        workspace.add_domain(Uuid::new_v4(), "finance".to_string());
908        workspace.add_domain(Uuid::new_v4(), "risk".to_string());
909        workspace.add_asset(AssetReference {
910            id: Uuid::new_v4(),
911            name: "accounts".to_string(),
912            domain: "finance".to_string(),
913            system: None,
914            asset_type: AssetType::Odcs,
915            file_path: None,
916        });
917
918        let yaml = workspace.to_yaml().unwrap();
919        let parsed = Workspace::from_yaml(&yaml).unwrap();
920
921        assert_eq!(workspace.id, parsed.id);
922        assert_eq!(workspace.name, parsed.name);
923        assert_eq!(workspace.domains.len(), parsed.domains.len());
924        assert_eq!(workspace.assets.len(), parsed.assets.len());
925    }
926
927    #[test]
928    fn test_workspace_json_roundtrip() {
929        let workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
930
931        let json = workspace.to_json().unwrap();
932        let parsed = Workspace::from_json(&json).unwrap();
933
934        assert_eq!(workspace.id, parsed.id);
935        assert_eq!(workspace.name, parsed.name);
936    }
937
938    #[test]
939    fn test_asset_type_extension() {
940        assert_eq!(AssetType::Workspace.extension(), "yaml");
941        assert_eq!(AssetType::Relationships.extension(), "yaml");
942        assert_eq!(AssetType::Odcs.extension(), "odcs.yaml");
943        assert_eq!(AssetType::Odps.extension(), "odps.yaml");
944        assert_eq!(AssetType::Cads.extension(), "cads.yaml");
945        assert_eq!(AssetType::Bpmn.extension(), "bpmn.xml");
946        assert_eq!(AssetType::Dmn.extension(), "dmn.xml");
947        assert_eq!(AssetType::Openapi.extension(), "openapi.yaml");
948        assert_eq!(AssetType::Sketch.extension(), "sketch.yaml");
949        assert_eq!(AssetType::SketchIndex.extension(), "yaml");
950        assert_eq!(AssetType::Dbmv.extension(), "dbmv.yaml");
951    }
952
953    #[test]
954    fn test_asset_type_filename() {
955        assert_eq!(AssetType::Workspace.filename(), Some("workspace.yaml"));
956        assert_eq!(
957            AssetType::Relationships.filename(),
958            Some("relationships.yaml")
959        );
960        assert_eq!(AssetType::Odcs.filename(), None);
961        assert_eq!(AssetType::Dbmv.filename(), None);
962        assert_eq!(AssetType::Sketch.filename(), None);
963        assert_eq!(AssetType::SketchIndex.filename(), Some("sketches.yaml"));
964    }
965
966    #[test]
967    fn test_asset_type_from_filename() {
968        assert_eq!(
969            AssetType::from_filename("workspace.yaml"),
970            Some(AssetType::Workspace)
971        );
972        assert_eq!(
973            AssetType::from_filename("relationships.yaml"),
974            Some(AssetType::Relationships)
975        );
976        assert_eq!(
977            AssetType::from_filename("test.odcs.yaml"),
978            Some(AssetType::Odcs)
979        );
980        assert_eq!(
981            AssetType::from_filename("test.odps.yaml"),
982            Some(AssetType::Odps)
983        );
984        assert_eq!(
985            AssetType::from_filename("test.cads.yaml"),
986            Some(AssetType::Cads)
987        );
988        assert_eq!(
989            AssetType::from_filename("test.bpmn.xml"),
990            Some(AssetType::Bpmn)
991        );
992        assert_eq!(
993            AssetType::from_filename("test.dmn.xml"),
994            Some(AssetType::Dmn)
995        );
996        assert_eq!(
997            AssetType::from_filename("test.openapi.yaml"),
998            Some(AssetType::Openapi)
999        );
1000        assert_eq!(
1001            AssetType::from_filename("test.openapi.json"),
1002            Some(AssetType::Openapi)
1003        );
1004        assert_eq!(
1005            AssetType::from_filename("test.sketch.yaml"),
1006            Some(AssetType::Sketch)
1007        );
1008        assert_eq!(
1009            AssetType::from_filename("sketches.yaml"),
1010            Some(AssetType::SketchIndex)
1011        );
1012        assert_eq!(
1013            AssetType::from_filename("test.dbmv.yaml"),
1014            Some(AssetType::Dbmv)
1015        );
1016        assert_eq!(AssetType::from_filename("random.txt"), None);
1017        assert_eq!(AssetType::from_filename("test.yaml"), None);
1018    }
1019
1020    #[test]
1021    fn test_asset_type_is_supported_file() {
1022        assert!(AssetType::is_supported_file("workspace.yaml"));
1023        assert!(AssetType::is_supported_file("relationships.yaml"));
1024        assert!(AssetType::is_supported_file(
1025            "enterprise_sales_orders.odcs.yaml"
1026        ));
1027        assert!(AssetType::is_supported_file(
1028            "enterprise_sales_metrics.dbmv.yaml"
1029        ));
1030        assert!(AssetType::is_supported_file("test.sketch.yaml"));
1031        assert!(AssetType::is_supported_file("sketches.yaml"));
1032        assert!(!AssetType::is_supported_file("readme.md"));
1033        assert!(!AssetType::is_supported_file("config.json"));
1034    }
1035
1036    #[test]
1037    fn test_asset_type_is_workspace_level() {
1038        assert!(AssetType::Workspace.is_workspace_level());
1039        assert!(AssetType::Relationships.is_workspace_level());
1040        assert!(!AssetType::Odcs.is_workspace_level());
1041        assert!(!AssetType::Odps.is_workspace_level());
1042        assert!(!AssetType::Dbmv.is_workspace_level());
1043        assert!(!AssetType::Sketch.is_workspace_level());
1044    }
1045
1046    #[test]
1047    fn test_sanitize_name() {
1048        assert_eq!(sanitize_name("Hello World"), "hello-world");
1049        assert_eq!(sanitize_name("Test/Path"), "test-path");
1050        assert_eq!(sanitize_name("Normal"), "normal");
1051    }
1052
1053    #[test]
1054    fn test_environment_connection_serialization() {
1055        let env = EnvironmentConnection {
1056            environment: "production".to_string(),
1057            owner: Some("Platform Team".to_string()),
1058            contact_details: Some(ContactDetails {
1059                email: Some("platform@example.com".to_string()),
1060                phone: None,
1061                name: Some("Platform Team".to_string()),
1062                role: Some("Data Owner".to_string()),
1063                other: None,
1064            }),
1065            sla: Some(vec![SlaProperty {
1066                property: "availability".to_string(),
1067                value: serde_json::json!(99.9),
1068                unit: "percent".to_string(),
1069                element: None,
1070                driver: Some("operational".to_string()),
1071                description: Some("99.9% uptime SLA".to_string()),
1072                scheduler: None,
1073                schedule: None,
1074            }]),
1075            auth_method: Some(AuthMethod::IamRole),
1076            support_team: Some("platform-oncall".to_string()),
1077            connection_string: None,
1078            secret_link: Some("https://vault.example.com/secrets/db-prod".to_string()),
1079            endpoint: Some("db-prod.example.com".to_string()),
1080            port: Some(5432),
1081            region: Some("us-east-1".to_string()),
1082            status: Some(EnvironmentStatus::Active),
1083            notes: Some("Primary production database".to_string()),
1084            custom_properties: HashMap::new(),
1085        };
1086
1087        let json = serde_json::to_string(&env).unwrap();
1088        let parsed: EnvironmentConnection = serde_json::from_str(&json).unwrap();
1089
1090        assert_eq!(env.environment, parsed.environment);
1091        assert_eq!(env.owner, parsed.owner);
1092        assert_eq!(env.auth_method, parsed.auth_method);
1093        assert_eq!(env.endpoint, parsed.endpoint);
1094        assert_eq!(env.port, parsed.port);
1095        assert_eq!(env.status, parsed.status);
1096    }
1097
1098    #[test]
1099    fn test_system_reference_with_environments() {
1100        let system = SystemReference {
1101            id: Uuid::new_v4(),
1102            name: "postgres-main".to_string(),
1103            description: Some("Main PostgreSQL cluster".to_string()),
1104            system_type: Some(InfrastructureType::PostgreSQL),
1105            table_ids: vec![],
1106            asset_ids: vec![],
1107            environments: vec![
1108                EnvironmentConnection {
1109                    environment: "production".to_string(),
1110                    owner: Some("Database Team".to_string()),
1111                    contact_details: None,
1112                    sla: None,
1113                    auth_method: Some(AuthMethod::IamRole),
1114                    support_team: Some("dba-oncall".to_string()),
1115                    connection_string: None,
1116                    secret_link: Some("https://vault.example.com/secrets/pg-prod".to_string()),
1117                    endpoint: Some("postgres-prod.example.com".to_string()),
1118                    port: Some(5432),
1119                    region: Some("us-east-1".to_string()),
1120                    status: Some(EnvironmentStatus::Active),
1121                    notes: None,
1122                    custom_properties: HashMap::new(),
1123                },
1124                EnvironmentConnection {
1125                    environment: "staging".to_string(),
1126                    owner: Some("Database Team".to_string()),
1127                    contact_details: None,
1128                    sla: None,
1129                    auth_method: Some(AuthMethod::BasicAuth),
1130                    support_team: None,
1131                    connection_string: None,
1132                    secret_link: None,
1133                    endpoint: Some("postgres-staging.example.com".to_string()),
1134                    port: Some(5432),
1135                    region: Some("us-east-1".to_string()),
1136                    status: Some(EnvironmentStatus::Active),
1137                    notes: None,
1138                    custom_properties: HashMap::new(),
1139                },
1140            ],
1141        };
1142
1143        // Test JSON serialization roundtrip
1144        let json = serde_json::to_string(&system).unwrap();
1145        let parsed: SystemReference = serde_json::from_str(&json).unwrap();
1146
1147        assert_eq!(system.id, parsed.id);
1148        assert_eq!(system.name, parsed.name);
1149        assert_eq!(system.system_type, parsed.system_type);
1150        assert_eq!(system.environments.len(), 2);
1151        assert_eq!(parsed.environments[0].environment, "production");
1152        assert_eq!(parsed.environments[1].environment, "staging");
1153
1154        // Test YAML serialization roundtrip
1155        let yaml = serde_yaml::to_string(&system).unwrap();
1156        let parsed_yaml: SystemReference = serde_yaml::from_str(&yaml).unwrap();
1157
1158        assert_eq!(system.id, parsed_yaml.id);
1159        assert_eq!(system.environments.len(), parsed_yaml.environments.len());
1160    }
1161
1162    #[test]
1163    fn test_backward_compatibility_no_environments() {
1164        // Ensure old YAML without environments field still parses
1165        let yaml = r#"
1166id: 550e8400-e29b-41d4-a716-446655440000
1167name: legacy-system
1168description: A legacy system without environments
1169"#;
1170        let parsed: SystemReference = serde_yaml::from_str(yaml).unwrap();
1171        assert!(parsed.environments.is_empty());
1172        assert!(parsed.system_type.is_none());
1173        assert_eq!(parsed.name, "legacy-system");
1174    }
1175
1176    #[test]
1177    fn test_backward_compatibility_no_system_type() {
1178        // Ensure old JSON without system_type field still parses
1179        let json = r#"{
1180            "id": "550e8400-e29b-41d4-a716-446655440000",
1181            "name": "old-system",
1182            "tableIds": ["660e8400-e29b-41d4-a716-446655440001"]
1183        }"#;
1184        let parsed: SystemReference = serde_json::from_str(json).unwrap();
1185        assert!(parsed.system_type.is_none());
1186        assert!(parsed.environments.is_empty());
1187        assert_eq!(parsed.table_ids.len(), 1);
1188    }
1189
1190    #[test]
1191    fn test_auth_method_serialization() {
1192        // Test that auth methods serialize to camelCase
1193        let env = EnvironmentConnection {
1194            environment: "test".to_string(),
1195            owner: None,
1196            contact_details: None,
1197            sla: None,
1198            auth_method: Some(AuthMethod::AwsSignatureV4),
1199            support_team: None,
1200            connection_string: None,
1201            secret_link: None,
1202            endpoint: None,
1203            port: None,
1204            region: None,
1205            status: None,
1206            notes: None,
1207            custom_properties: HashMap::new(),
1208        };
1209
1210        let json = serde_json::to_string(&env).unwrap();
1211        assert!(json.contains("awsSignatureV4"));
1212
1213        // Test parsing back
1214        let parsed: EnvironmentConnection = serde_json::from_str(&json).unwrap();
1215        assert_eq!(parsed.auth_method, Some(AuthMethod::AwsSignatureV4));
1216    }
1217
1218    #[test]
1219    fn test_environment_status_default() {
1220        // Test that EnvironmentStatus defaults to Active
1221        let status: EnvironmentStatus = Default::default();
1222        assert_eq!(status, EnvironmentStatus::Active);
1223    }
1224
1225    #[test]
1226    fn test_system_type_serialization() {
1227        let system = SystemReference {
1228            id: Uuid::new_v4(),
1229            name: "kafka-cluster".to_string(),
1230            description: None,
1231            system_type: Some(InfrastructureType::Kafka),
1232            table_ids: vec![],
1233            asset_ids: vec![],
1234            environments: vec![],
1235        };
1236
1237        let json = serde_json::to_string(&system).unwrap();
1238        assert!(json.contains("\"systemType\":\"Kafka\""));
1239
1240        let parsed: SystemReference = serde_json::from_str(&json).unwrap();
1241        assert_eq!(parsed.system_type, Some(InfrastructureType::Kafka));
1242    }
1243
1244    #[test]
1245    fn test_domain_with_shared_resources() {
1246        let domain = DomainReference {
1247            id: Uuid::new_v4(),
1248            name: "sales".to_string(),
1249            description: Some("Sales domain".to_string()),
1250            systems: vec![],
1251            shared_resources: vec![
1252                SharedResource {
1253                    id: Uuid::new_v4(),
1254                    name: "common-schema".to_string(),
1255                    resource_type: "schema".to_string(),
1256                    url: Some("https://github.com/org/schemas/common".to_string()),
1257                    description: Some("Common schema definitions".to_string()),
1258                },
1259                SharedResource {
1260                    id: Uuid::new_v4(),
1261                    name: "validation-library".to_string(),
1262                    resource_type: "library".to_string(),
1263                    url: None,
1264                    description: None,
1265                },
1266            ],
1267            transformation_links: vec![],
1268            table_visibility: None,
1269            view_positions: HashMap::new(),
1270        };
1271
1272        let json = serde_json::to_string(&domain).unwrap();
1273        let parsed: DomainReference = serde_json::from_str(&json).unwrap();
1274
1275        assert_eq!(parsed.shared_resources.len(), 2);
1276        assert_eq!(parsed.shared_resources[0].name, "common-schema");
1277        assert_eq!(parsed.shared_resources[0].resource_type, "schema");
1278    }
1279
1280    #[test]
1281    fn test_domain_with_transformation_links() {
1282        let domain = DomainReference {
1283            id: Uuid::new_v4(),
1284            name: "analytics".to_string(),
1285            description: None,
1286            systems: vec![],
1287            shared_resources: vec![],
1288            transformation_links: vec![
1289                TransformationLink {
1290                    id: Uuid::new_v4(),
1291                    name: "sales-etl".to_string(),
1292                    transformation_type: Some("dbt".to_string()),
1293                    url: Some("https://github.com/org/dbt-models/sales".to_string()),
1294                    description: Some("Sales data transformation".to_string()),
1295                },
1296                TransformationLink {
1297                    id: Uuid::new_v4(),
1298                    name: "aggregation-pipeline".to_string(),
1299                    transformation_type: Some("spark".to_string()),
1300                    url: None,
1301                    description: None,
1302                },
1303            ],
1304            table_visibility: Some(TableVisibility::DomainOnly),
1305            view_positions: HashMap::new(),
1306        };
1307
1308        let yaml = serde_yaml::to_string(&domain).unwrap();
1309        let parsed: DomainReference = serde_yaml::from_str(&yaml).unwrap();
1310
1311        assert_eq!(parsed.transformation_links.len(), 2);
1312        assert_eq!(parsed.transformation_links[0].name, "sales-etl");
1313        assert_eq!(
1314            parsed.transformation_links[0].transformation_type,
1315            Some("dbt".to_string())
1316        );
1317        assert_eq!(parsed.table_visibility, Some(TableVisibility::DomainOnly));
1318    }
1319
1320    #[test]
1321    fn test_table_visibility_default() {
1322        let visibility: TableVisibility = Default::default();
1323        assert_eq!(visibility, TableVisibility::Public);
1324    }
1325
1326    #[test]
1327    fn test_table_visibility_serialization() {
1328        let domain = DomainReference {
1329            id: Uuid::new_v4(),
1330            name: "private-domain".to_string(),
1331            description: None,
1332            systems: vec![],
1333            shared_resources: vec![],
1334            transformation_links: vec![],
1335            table_visibility: Some(TableVisibility::Hidden),
1336            view_positions: HashMap::new(),
1337        };
1338
1339        let json = serde_json::to_string(&domain).unwrap();
1340        assert!(json.contains("\"tableVisibility\":\"hidden\""));
1341
1342        let parsed: DomainReference = serde_json::from_str(&json).unwrap();
1343        assert_eq!(parsed.table_visibility, Some(TableVisibility::Hidden));
1344    }
1345
1346    #[test]
1347    fn test_domain_backward_compatibility_no_new_fields() {
1348        // Ensure old YAML without shared_resources, transformation_links, table_visibility still parses
1349        let yaml = r#"
1350id: 550e8400-e29b-41d4-a716-446655440000
1351name: legacy-domain
1352description: A legacy domain
1353systems: []
1354"#;
1355        let parsed: DomainReference = serde_yaml::from_str(yaml).unwrap();
1356        assert!(parsed.shared_resources.is_empty());
1357        assert!(parsed.transformation_links.is_empty());
1358        assert!(parsed.table_visibility.is_none());
1359        assert_eq!(parsed.name, "legacy-domain");
1360    }
1361}