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