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;
23
24/// Asset reference within a workspace
25///
26/// Contains information about an asset file and its location in the domain/system hierarchy.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "camelCase")]
29pub struct AssetReference {
30    /// Asset identifier (UUID)
31    pub id: Uuid,
32    /// Asset name
33    pub name: String,
34    /// Domain name this asset belongs to
35    pub domain: String,
36    /// Optional system name (if asset is within a system)
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub system: Option<String>,
39    /// Asset type (odcs, odps, cads)
40    #[serde(alias = "asset_type")]
41    pub asset_type: AssetType,
42    /// File path relative to workspace (generated from naming convention)
43    #[serde(skip_serializing_if = "Option::is_none", alias = "file_path")]
44    pub file_path: Option<String>,
45}
46
47/// Type of asset or file in the workspace
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(rename_all = "lowercase")]
50pub enum AssetType {
51    /// Workspace configuration file
52    Workspace,
53    /// Relationships file
54    Relationships,
55    /// ODCS table definition
56    Odcs,
57    /// ODPS data product
58    Odps,
59    /// CADS compute asset
60    Cads,
61    /// BPMN process model
62    Bpmn,
63    /// DMN decision model
64    Dmn,
65    /// OpenAPI specification
66    Openapi,
67    /// MADR decision record
68    Decision,
69    /// Knowledge base article
70    Knowledge,
71    /// Decision log index file
72    DecisionIndex,
73    /// Knowledge base index file
74    KnowledgeIndex,
75}
76
77impl AssetType {
78    /// Get file extension for this asset type
79    pub fn extension(&self) -> &'static str {
80        match self {
81            AssetType::Workspace => "yaml",
82            AssetType::Relationships => "yaml",
83            AssetType::Odcs => "odcs.yaml",
84            AssetType::Odps => "odps.yaml",
85            AssetType::Cads => "cads.yaml",
86            AssetType::Bpmn => "bpmn.xml",
87            AssetType::Dmn => "dmn.xml",
88            AssetType::Openapi => "openapi.yaml",
89            AssetType::Decision => "madr.yaml",
90            AssetType::Knowledge => "kb.yaml",
91            AssetType::DecisionIndex => "yaml",
92            AssetType::KnowledgeIndex => "yaml",
93        }
94    }
95
96    /// Get the full filename for workspace-level files
97    pub fn filename(&self) -> Option<&'static str> {
98        match self {
99            AssetType::Workspace => Some("workspace.yaml"),
100            AssetType::Relationships => Some("relationships.yaml"),
101            AssetType::DecisionIndex => Some("decisions.yaml"),
102            AssetType::KnowledgeIndex => Some("knowledge.yaml"),
103            _ => None,
104        }
105    }
106
107    /// Check if this is a workspace-level file (not a domain/system asset)
108    pub fn is_workspace_level(&self) -> bool {
109        matches!(
110            self,
111            AssetType::Workspace
112                | AssetType::Relationships
113                | AssetType::DecisionIndex
114                | AssetType::KnowledgeIndex
115        )
116    }
117
118    /// Detect asset type from filename
119    pub fn from_filename(filename: &str) -> Option<Self> {
120        if filename == "workspace.yaml" {
121            Some(AssetType::Workspace)
122        } else if filename == "relationships.yaml" {
123            Some(AssetType::Relationships)
124        } else if filename == "decisions.yaml" {
125            Some(AssetType::DecisionIndex)
126        } else if filename == "knowledge.yaml" {
127            Some(AssetType::KnowledgeIndex)
128        } else if filename.ends_with(".odcs.yaml") {
129            Some(AssetType::Odcs)
130        } else if filename.ends_with(".odps.yaml") {
131            Some(AssetType::Odps)
132        } else if filename.ends_with(".cads.yaml") {
133            Some(AssetType::Cads)
134        } else if filename.ends_with(".madr.yaml") {
135            Some(AssetType::Decision)
136        } else if filename.ends_with(".kb.yaml") {
137            Some(AssetType::Knowledge)
138        } else if filename.ends_with(".bpmn.xml") {
139            Some(AssetType::Bpmn)
140        } else if filename.ends_with(".dmn.xml") {
141            Some(AssetType::Dmn)
142        } else if filename.ends_with(".openapi.yaml") || filename.ends_with(".openapi.json") {
143            Some(AssetType::Openapi)
144        } else {
145            None
146        }
147    }
148
149    /// Get all supported file extensions
150    pub fn supported_extensions() -> &'static [&'static str] {
151        &[
152            "workspace.yaml",
153            "relationships.yaml",
154            "decisions.yaml",
155            "knowledge.yaml",
156            ".odcs.yaml",
157            ".odps.yaml",
158            ".cads.yaml",
159            ".madr.yaml",
160            ".kb.yaml",
161            ".bpmn.xml",
162            ".dmn.xml",
163            ".openapi.yaml",
164            ".openapi.json",
165        ]
166    }
167
168    /// Check if a filename is a supported asset type
169    pub fn is_supported_file(filename: &str) -> bool {
170        Self::from_filename(filename).is_some()
171    }
172}
173
174/// Domain reference within a workspace
175///
176/// Contains information about a domain and its systems.
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
178#[serde(rename_all = "camelCase")]
179pub struct DomainReference {
180    /// Domain identifier
181    pub id: Uuid,
182    /// Domain name
183    pub name: String,
184    /// Optional description
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub description: Option<String>,
187    /// Systems within this domain
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub systems: Vec<SystemReference>,
190    /// View positions for different view modes (operational, analytical, process, systems)
191    /// Key: view mode name, Value: Map of entity ID to position
192    #[serde(
193        default,
194        skip_serializing_if = "HashMap::is_empty",
195        alias = "view_positions"
196    )]
197    pub view_positions: HashMap<String, HashMap<String, ViewPosition>>,
198}
199
200/// System reference within a domain
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
202#[serde(rename_all = "camelCase")]
203pub struct SystemReference {
204    /// System identifier
205    pub id: Uuid,
206    /// System name
207    pub name: String,
208    /// Optional description
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub description: Option<String>,
211    /// Optional array of table UUIDs that belong to this system.
212    /// When present, provides explicit table-to-system mapping without requiring parsing of individual ODCS files.
213    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "table_ids")]
214    pub table_ids: Vec<Uuid>,
215    /// Optional array of compute asset (CADS) UUIDs that belong to this system.
216    /// When present, provides explicit asset-to-system mapping.
217    #[serde(default, skip_serializing_if = "Vec::is_empty", alias = "asset_ids")]
218    pub asset_ids: Vec<Uuid>,
219}
220
221/// Workspace - Top-level container for domains, assets, and relationships
222///
223/// Workspaces organize domains, systems, and their associated assets.
224/// All files use a flat naming convention: `{workspace}_{domain}_{system}_{resource}.xxx.yaml`
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
226#[serde(rename_all = "camelCase")]
227pub struct Workspace {
228    /// Unique identifier for the workspace
229    pub id: Uuid,
230    /// Workspace name (used in file naming)
231    pub name: String,
232    /// Owner/creator user identifier
233    #[serde(alias = "owner_id")]
234    pub owner_id: Uuid,
235    /// Creation timestamp
236    #[serde(alias = "created_at")]
237    pub created_at: DateTime<Utc>,
238    /// Last modification timestamp
239    #[serde(alias = "last_modified_at")]
240    pub last_modified_at: DateTime<Utc>,
241    /// Domain references with their systems
242    #[serde(default)]
243    pub domains: Vec<DomainReference>,
244    /// All asset references in this workspace
245    #[serde(default)]
246    pub assets: Vec<AssetReference>,
247    /// Relationships between assets in this workspace
248    #[serde(default, skip_serializing_if = "Vec::is_empty")]
249    pub relationships: Vec<Relationship>,
250}
251
252impl Workspace {
253    /// Create a new Workspace
254    pub fn new(name: String, owner_id: Uuid) -> Self {
255        let now = Utc::now();
256        Self {
257            id: Uuid::new_v4(),
258            name,
259            owner_id,
260            created_at: now,
261            last_modified_at: now,
262            domains: Vec::new(),
263            assets: Vec::new(),
264            relationships: Vec::new(),
265        }
266    }
267
268    /// Create a workspace with a specific ID
269    pub fn with_id(id: Uuid, name: String, owner_id: Uuid) -> Self {
270        let now = Utc::now();
271        Self {
272            id,
273            name,
274            owner_id,
275            created_at: now,
276            last_modified_at: now,
277            domains: Vec::new(),
278            assets: Vec::new(),
279            relationships: Vec::new(),
280        }
281    }
282
283    /// Add a relationship to the workspace
284    pub fn add_relationship(&mut self, relationship: Relationship) {
285        // Check if relationship already exists
286        if self.relationships.iter().any(|r| r.id == relationship.id) {
287            return;
288        }
289        self.relationships.push(relationship);
290        self.last_modified_at = Utc::now();
291    }
292
293    /// Remove a relationship by ID
294    pub fn remove_relationship(&mut self, relationship_id: Uuid) -> bool {
295        let initial_len = self.relationships.len();
296        self.relationships.retain(|r| r.id != relationship_id);
297        let removed = self.relationships.len() < initial_len;
298        if removed {
299            self.last_modified_at = Utc::now();
300        }
301        removed
302    }
303
304    /// Get relationships by source table ID
305    pub fn get_relationships_for_source(&self, source_table_id: Uuid) -> Vec<&Relationship> {
306        self.relationships
307            .iter()
308            .filter(|r| r.source_table_id == source_table_id)
309            .collect()
310    }
311
312    /// Get relationships by target table ID
313    pub fn get_relationships_for_target(&self, target_table_id: Uuid) -> Vec<&Relationship> {
314        self.relationships
315            .iter()
316            .filter(|r| r.target_table_id == target_table_id)
317            .collect()
318    }
319
320    /// Add a domain reference to the workspace
321    pub fn add_domain(&mut self, domain_id: Uuid, domain_name: String) {
322        // Check if domain already exists
323        if self.domains.iter().any(|d| d.id == domain_id) {
324            return;
325        }
326        self.domains.push(DomainReference {
327            id: domain_id,
328            name: domain_name,
329            description: None,
330            systems: Vec::new(),
331            view_positions: HashMap::new(),
332        });
333        self.last_modified_at = Utc::now();
334    }
335
336    /// Add a domain with description
337    pub fn add_domain_with_description(
338        &mut self,
339        domain_id: Uuid,
340        domain_name: String,
341        description: Option<String>,
342    ) {
343        if self.domains.iter().any(|d| d.id == domain_id) {
344            return;
345        }
346        self.domains.push(DomainReference {
347            id: domain_id,
348            name: domain_name,
349            description,
350            systems: Vec::new(),
351            view_positions: HashMap::new(),
352        });
353        self.last_modified_at = Utc::now();
354    }
355
356    /// Add a system to a domain
357    pub fn add_system_to_domain(
358        &mut self,
359        domain_name: &str,
360        system_id: Uuid,
361        system_name: String,
362        description: Option<String>,
363    ) -> bool {
364        if let Some(domain) = self.domains.iter_mut().find(|d| d.name == domain_name)
365            && !domain.systems.iter().any(|s| s.id == system_id)
366        {
367            domain.systems.push(SystemReference {
368                id: system_id,
369                name: system_name,
370                description,
371                table_ids: Vec::new(),
372                asset_ids: Vec::new(),
373            });
374            self.last_modified_at = Utc::now();
375            return true;
376        }
377        false
378    }
379
380    /// Remove a domain reference by ID
381    pub fn remove_domain(&mut self, domain_id: Uuid) -> bool {
382        let initial_len = self.domains.len();
383        self.domains.retain(|d| d.id != domain_id);
384        // Also remove assets belonging to this domain
385        if let Some(domain) = self.domains.iter().find(|d| d.id == domain_id) {
386            let domain_name = domain.name.clone();
387            self.assets.retain(|a| a.domain != domain_name);
388        }
389        if self.domains.len() != initial_len {
390            self.last_modified_at = Utc::now();
391            true
392        } else {
393            false
394        }
395    }
396
397    /// Get a domain reference by ID
398    pub fn get_domain(&self, domain_id: Uuid) -> Option<&DomainReference> {
399        self.domains.iter().find(|d| d.id == domain_id)
400    }
401
402    /// Get a domain reference by name
403    pub fn get_domain_by_name(&self, name: &str) -> Option<&DomainReference> {
404        self.domains.iter().find(|d| d.name == name)
405    }
406
407    /// Add an asset reference
408    pub fn add_asset(&mut self, asset: AssetReference) {
409        // Check if asset already exists
410        if self.assets.iter().any(|a| a.id == asset.id) {
411            return;
412        }
413        self.assets.push(asset);
414        self.last_modified_at = Utc::now();
415    }
416
417    /// Remove an asset by ID
418    pub fn remove_asset(&mut self, asset_id: Uuid) -> bool {
419        let initial_len = self.assets.len();
420        self.assets.retain(|a| a.id != asset_id);
421        if self.assets.len() != initial_len {
422            self.last_modified_at = Utc::now();
423            true
424        } else {
425            false
426        }
427    }
428
429    /// Get an asset by ID
430    pub fn get_asset(&self, asset_id: Uuid) -> Option<&AssetReference> {
431        self.assets.iter().find(|a| a.id == asset_id)
432    }
433
434    /// Get assets by domain
435    pub fn get_assets_by_domain(&self, domain_name: &str) -> Vec<&AssetReference> {
436        self.assets
437            .iter()
438            .filter(|a| a.domain == domain_name)
439            .collect()
440    }
441
442    /// Get assets by type
443    pub fn get_assets_by_type(&self, asset_type: &AssetType) -> Vec<&AssetReference> {
444        self.assets
445            .iter()
446            .filter(|a| &a.asset_type == asset_type)
447            .collect()
448    }
449
450    /// Generate filename for an asset using the naming convention
451    /// Format: {workspace}_{domain}_{system}_{resource}.{extension}
452    pub fn generate_asset_filename(&self, asset: &AssetReference) -> String {
453        let mut parts = vec![sanitize_name(&self.name), sanitize_name(&asset.domain)];
454
455        if let Some(ref system) = asset.system {
456            parts.push(sanitize_name(system));
457        }
458
459        parts.push(sanitize_name(&asset.name));
460
461        format!("{}.{}", parts.join("_"), asset.asset_type.extension())
462    }
463
464    /// Parse a filename to extract workspace, domain, system, and resource names
465    /// Returns (domain, system, resource_name) or None if parsing fails
466    pub fn parse_asset_filename(
467        filename: &str,
468    ) -> Option<(String, Option<String>, String, AssetType)> {
469        // Determine asset type from extension
470        let (base, asset_type) = if filename.ends_with(".odcs.yaml") {
471            (filename.strip_suffix(".odcs.yaml")?, AssetType::Odcs)
472        } else if filename.ends_with(".odps.yaml") {
473            (filename.strip_suffix(".odps.yaml")?, AssetType::Odps)
474        } else if filename.ends_with(".cads.yaml") {
475            (filename.strip_suffix(".cads.yaml")?, AssetType::Cads)
476        } else if filename.ends_with(".bpmn.xml") {
477            (filename.strip_suffix(".bpmn.xml")?, AssetType::Bpmn)
478        } else if filename.ends_with(".dmn.xml") {
479            (filename.strip_suffix(".dmn.xml")?, AssetType::Dmn)
480        } else if filename.ends_with(".openapi.yaml") {
481            (filename.strip_suffix(".openapi.yaml")?, AssetType::Openapi)
482        } else {
483            return None;
484        };
485
486        let parts: Vec<&str> = base.split('_').collect();
487
488        match parts.len() {
489            // workspace_domain_resource (no system)
490            3 => Some((parts[1].to_string(), None, parts[2].to_string(), asset_type)),
491            // workspace_domain_system_resource
492            4 => Some((
493                parts[1].to_string(),
494                Some(parts[2].to_string()),
495                parts[3].to_string(),
496                asset_type,
497            )),
498            // More than 4 parts - treat remaining as resource name with underscores
499            n if n > 4 => Some((
500                parts[1].to_string(),
501                Some(parts[2].to_string()),
502                parts[3..].join("_"),
503                asset_type,
504            )),
505            _ => None,
506        }
507    }
508
509    /// Import workspace from YAML
510    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
511        serde_yaml::from_str(yaml_content)
512    }
513
514    /// Export workspace to YAML
515    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
516        serde_yaml::to_string(self)
517    }
518
519    /// Import workspace from JSON
520    pub fn from_json(json_content: &str) -> Result<Self, serde_json::Error> {
521        serde_json::from_str(json_content)
522    }
523
524    /// Export workspace to JSON
525    pub fn to_json(&self) -> Result<String, serde_json::Error> {
526        serde_json::to_string(self)
527    }
528
529    /// Export workspace to pretty JSON
530    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
531        serde_json::to_string_pretty(self)
532    }
533}
534
535/// Sanitize a name for use in filenames (replace spaces/special chars with hyphens)
536fn sanitize_name(name: &str) -> String {
537    name.chars()
538        .map(|c| match c {
539            ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
540            _ => c,
541        })
542        .collect::<String>()
543        .to_lowercase()
544}
545
546impl Default for Workspace {
547    fn default() -> Self {
548        Self::new("Default Workspace".to_string(), Uuid::new_v4())
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn test_workspace_new() {
558        let owner_id = Uuid::new_v4();
559        let workspace = Workspace::new("Test Workspace".to_string(), owner_id);
560
561        assert_eq!(workspace.name, "Test Workspace");
562        assert_eq!(workspace.owner_id, owner_id);
563        assert!(workspace.domains.is_empty());
564        assert!(workspace.assets.is_empty());
565    }
566
567    #[test]
568    fn test_workspace_add_domain() {
569        let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
570        let domain_id = Uuid::new_v4();
571
572        workspace.add_domain(domain_id, "customer-management".to_string());
573
574        assert_eq!(workspace.domains.len(), 1);
575        assert_eq!(workspace.domains[0].id, domain_id);
576        assert_eq!(workspace.domains[0].name, "customer-management");
577
578        // Adding same domain again should not duplicate
579        workspace.add_domain(domain_id, "customer-management".to_string());
580        assert_eq!(workspace.domains.len(), 1);
581    }
582
583    #[test]
584    fn test_workspace_add_system_to_domain() {
585        let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
586        let domain_id = Uuid::new_v4();
587        let system_id = Uuid::new_v4();
588
589        workspace.add_domain(domain_id, "sales".to_string());
590        let result = workspace.add_system_to_domain(
591            "sales",
592            system_id,
593            "kafka".to_string(),
594            Some("Kafka streaming".to_string()),
595        );
596
597        assert!(result);
598        assert_eq!(workspace.domains[0].systems.len(), 1);
599        assert_eq!(workspace.domains[0].systems[0].name, "kafka");
600    }
601
602    #[test]
603    fn test_workspace_remove_domain() {
604        let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
605        let domain_id = Uuid::new_v4();
606        workspace.add_domain(domain_id, "test-domain".to_string());
607
608        assert!(workspace.remove_domain(domain_id));
609        assert!(workspace.domains.is_empty());
610        assert!(!workspace.remove_domain(domain_id)); // Already removed
611    }
612
613    #[test]
614    fn test_workspace_add_asset() {
615        let mut workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
616        let asset_id = Uuid::new_v4();
617
618        let asset = AssetReference {
619            id: asset_id,
620            name: "orders".to_string(),
621            domain: "sales".to_string(),
622            system: Some("kafka".to_string()),
623            asset_type: AssetType::Odcs,
624            file_path: None,
625        };
626
627        workspace.add_asset(asset);
628        assert_eq!(workspace.assets.len(), 1);
629        assert_eq!(workspace.assets[0].name, "orders");
630    }
631
632    #[test]
633    fn test_workspace_generate_asset_filename() {
634        let workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
635
636        // With system
637        let asset_with_system = AssetReference {
638            id: Uuid::new_v4(),
639            name: "orders".to_string(),
640            domain: "sales".to_string(),
641            system: Some("kafka".to_string()),
642            asset_type: AssetType::Odcs,
643            file_path: None,
644        };
645        assert_eq!(
646            workspace.generate_asset_filename(&asset_with_system),
647            "enterprise_sales_kafka_orders.odcs.yaml"
648        );
649
650        // Without system
651        let asset_no_system = AssetReference {
652            id: Uuid::new_v4(),
653            name: "customers".to_string(),
654            domain: "crm".to_string(),
655            system: None,
656            asset_type: AssetType::Odcs,
657            file_path: None,
658        };
659        assert_eq!(
660            workspace.generate_asset_filename(&asset_no_system),
661            "enterprise_crm_customers.odcs.yaml"
662        );
663
664        // ODPS product
665        let odps_asset = AssetReference {
666            id: Uuid::new_v4(),
667            name: "analytics".to_string(),
668            domain: "finance".to_string(),
669            system: None,
670            asset_type: AssetType::Odps,
671            file_path: None,
672        };
673        assert_eq!(
674            workspace.generate_asset_filename(&odps_asset),
675            "enterprise_finance_analytics.odps.yaml"
676        );
677    }
678
679    #[test]
680    fn test_workspace_parse_asset_filename() {
681        // With system
682        let result = Workspace::parse_asset_filename("enterprise_sales_kafka_orders.odcs.yaml");
683        assert!(result.is_some());
684        let (domain, system, name, asset_type) = result.unwrap();
685        assert_eq!(domain, "sales");
686        assert_eq!(system, Some("kafka".to_string()));
687        assert_eq!(name, "orders");
688        assert_eq!(asset_type, AssetType::Odcs);
689
690        // Without system (3 parts)
691        let result = Workspace::parse_asset_filename("enterprise_crm_customers.odcs.yaml");
692        assert!(result.is_some());
693        let (domain, system, name, asset_type) = result.unwrap();
694        assert_eq!(domain, "crm");
695        assert_eq!(system, None);
696        assert_eq!(name, "customers");
697        assert_eq!(asset_type, AssetType::Odcs);
698
699        // ODPS type
700        let result = Workspace::parse_asset_filename("workspace_domain_product.odps.yaml");
701        assert!(result.is_some());
702        let (_, _, _, asset_type) = result.unwrap();
703        assert_eq!(asset_type, AssetType::Odps);
704    }
705
706    #[test]
707    fn test_workspace_yaml_roundtrip() {
708        let mut workspace = Workspace::new("Enterprise Models".to_string(), Uuid::new_v4());
709        workspace.add_domain(Uuid::new_v4(), "finance".to_string());
710        workspace.add_domain(Uuid::new_v4(), "risk".to_string());
711        workspace.add_asset(AssetReference {
712            id: Uuid::new_v4(),
713            name: "accounts".to_string(),
714            domain: "finance".to_string(),
715            system: None,
716            asset_type: AssetType::Odcs,
717            file_path: None,
718        });
719
720        let yaml = workspace.to_yaml().unwrap();
721        let parsed = Workspace::from_yaml(&yaml).unwrap();
722
723        assert_eq!(workspace.id, parsed.id);
724        assert_eq!(workspace.name, parsed.name);
725        assert_eq!(workspace.domains.len(), parsed.domains.len());
726        assert_eq!(workspace.assets.len(), parsed.assets.len());
727    }
728
729    #[test]
730    fn test_workspace_json_roundtrip() {
731        let workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
732
733        let json = workspace.to_json().unwrap();
734        let parsed = Workspace::from_json(&json).unwrap();
735
736        assert_eq!(workspace.id, parsed.id);
737        assert_eq!(workspace.name, parsed.name);
738    }
739
740    #[test]
741    fn test_asset_type_extension() {
742        assert_eq!(AssetType::Workspace.extension(), "yaml");
743        assert_eq!(AssetType::Relationships.extension(), "yaml");
744        assert_eq!(AssetType::Odcs.extension(), "odcs.yaml");
745        assert_eq!(AssetType::Odps.extension(), "odps.yaml");
746        assert_eq!(AssetType::Cads.extension(), "cads.yaml");
747        assert_eq!(AssetType::Bpmn.extension(), "bpmn.xml");
748        assert_eq!(AssetType::Dmn.extension(), "dmn.xml");
749        assert_eq!(AssetType::Openapi.extension(), "openapi.yaml");
750    }
751
752    #[test]
753    fn test_asset_type_filename() {
754        assert_eq!(AssetType::Workspace.filename(), Some("workspace.yaml"));
755        assert_eq!(
756            AssetType::Relationships.filename(),
757            Some("relationships.yaml")
758        );
759        assert_eq!(AssetType::Odcs.filename(), None);
760    }
761
762    #[test]
763    fn test_asset_type_from_filename() {
764        assert_eq!(
765            AssetType::from_filename("workspace.yaml"),
766            Some(AssetType::Workspace)
767        );
768        assert_eq!(
769            AssetType::from_filename("relationships.yaml"),
770            Some(AssetType::Relationships)
771        );
772        assert_eq!(
773            AssetType::from_filename("test.odcs.yaml"),
774            Some(AssetType::Odcs)
775        );
776        assert_eq!(
777            AssetType::from_filename("test.odps.yaml"),
778            Some(AssetType::Odps)
779        );
780        assert_eq!(
781            AssetType::from_filename("test.cads.yaml"),
782            Some(AssetType::Cads)
783        );
784        assert_eq!(
785            AssetType::from_filename("test.bpmn.xml"),
786            Some(AssetType::Bpmn)
787        );
788        assert_eq!(
789            AssetType::from_filename("test.dmn.xml"),
790            Some(AssetType::Dmn)
791        );
792        assert_eq!(
793            AssetType::from_filename("test.openapi.yaml"),
794            Some(AssetType::Openapi)
795        );
796        assert_eq!(
797            AssetType::from_filename("test.openapi.json"),
798            Some(AssetType::Openapi)
799        );
800        assert_eq!(AssetType::from_filename("random.txt"), None);
801        assert_eq!(AssetType::from_filename("test.yaml"), None);
802    }
803
804    #[test]
805    fn test_asset_type_is_supported_file() {
806        assert!(AssetType::is_supported_file("workspace.yaml"));
807        assert!(AssetType::is_supported_file("relationships.yaml"));
808        assert!(AssetType::is_supported_file(
809            "enterprise_sales_orders.odcs.yaml"
810        ));
811        assert!(!AssetType::is_supported_file("readme.md"));
812        assert!(!AssetType::is_supported_file("config.json"));
813    }
814
815    #[test]
816    fn test_asset_type_is_workspace_level() {
817        assert!(AssetType::Workspace.is_workspace_level());
818        assert!(AssetType::Relationships.is_workspace_level());
819        assert!(!AssetType::Odcs.is_workspace_level());
820        assert!(!AssetType::Odps.is_workspace_level());
821    }
822
823    #[test]
824    fn test_sanitize_name() {
825        assert_eq!(sanitize_name("Hello World"), "hello-world");
826        assert_eq!(sanitize_name("Test/Path"), "test-path");
827        assert_eq!(sanitize_name("Normal"), "normal");
828    }
829}