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