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