Skip to main content

data_modelling_core/models/
workspace.rs

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