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