1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "camelCase")]
29pub struct AssetReference {
30 pub id: Uuid,
32 pub name: String,
34 pub domain: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub system: Option<String>,
39 pub asset_type: AssetType,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub file_path: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(rename_all = "lowercase")]
49pub enum AssetType {
50 Workspace,
52 Relationships,
54 Odcs,
56 Odps,
58 Cads,
60 Bpmn,
62 Dmn,
64 Openapi,
66 Decision,
68 Knowledge,
70 DecisionIndex,
72 KnowledgeIndex,
74}
75
76impl AssetType {
77 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 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 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 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 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 pub fn is_supported_file(filename: &str) -> bool {
169 Self::from_filename(filename).is_some()
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177#[serde(rename_all = "camelCase")]
178pub struct DomainReference {
179 pub id: Uuid,
181 pub name: String,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 pub description: Option<String>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub systems: Vec<SystemReference>,
189 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
192 pub view_positions: HashMap<String, HashMap<String, ViewPosition>>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
197#[serde(rename_all = "camelCase")]
198pub struct SystemReference {
199 pub id: Uuid,
201 pub name: String,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub description: Option<String>,
206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
209 pub table_ids: Vec<Uuid>,
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
213 pub asset_ids: Vec<Uuid>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
221#[serde(rename_all = "camelCase")]
222pub struct Workspace {
223 pub id: Uuid,
225 pub name: String,
227 pub owner_id: Uuid,
229 pub created_at: DateTime<Utc>,
231 pub last_modified_at: DateTime<Utc>,
233 #[serde(default)]
235 pub domains: Vec<DomainReference>,
236 #[serde(default)]
238 pub assets: Vec<AssetReference>,
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub relationships: Vec<Relationship>,
242}
243
244impl Workspace {
245 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 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 pub fn add_relationship(&mut self, relationship: Relationship) {
277 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 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 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 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 pub fn add_domain(&mut self, domain_id: Uuid, domain_name: String) {
314 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 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 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 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 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 pub fn get_domain(&self, domain_id: Uuid) -> Option<&DomainReference> {
391 self.domains.iter().find(|d| d.id == domain_id)
392 }
393
394 pub fn get_domain_by_name(&self, name: &str) -> Option<&DomainReference> {
396 self.domains.iter().find(|d| d.name == name)
397 }
398
399 pub fn add_asset(&mut self, asset: AssetReference) {
401 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 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 pub fn get_asset(&self, asset_id: Uuid) -> Option<&AssetReference> {
423 self.assets.iter().find(|a| a.id == asset_id)
424 }
425
426 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 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 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 pub fn parse_asset_filename(
459 filename: &str,
460 ) -> Option<(String, Option<String>, String, AssetType)> {
461 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 3 => Some((parts[1].to_string(), None, parts[2].to_string(), asset_type)),
483 4 => Some((
485 parts[1].to_string(),
486 Some(parts[2].to_string()),
487 parts[3].to_string(),
488 asset_type,
489 )),
490 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 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
503 serde_yaml::from_str(yaml_content)
504 }
505
506 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
508 serde_yaml::to_string(self)
509 }
510
511 pub fn from_json(json_content: &str) -> Result<Self, serde_json::Error> {
513 serde_json::from_str(json_content)
514 }
515
516 pub fn to_json(&self) -> Result<String, serde_json::Error> {
518 serde_json::to_string(self)
519 }
520
521 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
523 serde_json::to_string_pretty(self)
524 }
525}
526
527fn 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 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)); }
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 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 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 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 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 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 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}