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)]
28pub struct AssetReference {
29 pub id: Uuid,
31 pub name: String,
33 pub domain: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub system: Option<String>,
38 pub asset_type: AssetType,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub file_path: Option<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47#[serde(rename_all = "lowercase")]
48pub enum AssetType {
49 Workspace,
51 Relationships,
53 Odcs,
55 Odps,
57 Cads,
59 Bpmn,
61 Dmn,
63 Openapi,
65 Decision,
67 Knowledge,
69 DecisionIndex,
71 KnowledgeIndex,
73}
74
75impl AssetType {
76 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 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 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 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 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 pub fn is_supported_file(filename: &str) -> bool {
168 Self::from_filename(filename).is_some()
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
176pub struct DomainReference {
177 pub id: Uuid,
179 pub name: String,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub description: Option<String>,
184 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub systems: Vec<SystemReference>,
187 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
190 pub view_positions: HashMap<String, HashMap<String, ViewPosition>>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
195pub struct SystemReference {
196 pub id: Uuid,
198 pub name: String,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub description: Option<String>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub table_ids: Vec<Uuid>,
207 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub asset_ids: Vec<Uuid>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
218pub struct Workspace {
219 pub id: Uuid,
221 pub name: String,
223 pub owner_id: Uuid,
225 pub created_at: DateTime<Utc>,
227 pub last_modified_at: DateTime<Utc>,
229 #[serde(default)]
231 pub domains: Vec<DomainReference>,
232 #[serde(default)]
234 pub assets: Vec<AssetReference>,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub relationships: Vec<Relationship>,
238}
239
240impl Workspace {
241 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 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 pub fn add_relationship(&mut self, relationship: Relationship) {
273 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 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 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 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 pub fn add_domain(&mut self, domain_id: Uuid, domain_name: String) {
310 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 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 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 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 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 pub fn get_domain(&self, domain_id: Uuid) -> Option<&DomainReference> {
387 self.domains.iter().find(|d| d.id == domain_id)
388 }
389
390 pub fn get_domain_by_name(&self, name: &str) -> Option<&DomainReference> {
392 self.domains.iter().find(|d| d.name == name)
393 }
394
395 pub fn add_asset(&mut self, asset: AssetReference) {
397 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 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 pub fn get_asset(&self, asset_id: Uuid) -> Option<&AssetReference> {
419 self.assets.iter().find(|a| a.id == asset_id)
420 }
421
422 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 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 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 pub fn parse_asset_filename(
455 filename: &str,
456 ) -> Option<(String, Option<String>, String, AssetType)> {
457 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 3 => Some((parts[1].to_string(), None, parts[2].to_string(), asset_type)),
479 4 => Some((
481 parts[1].to_string(),
482 Some(parts[2].to_string()),
483 parts[3].to_string(),
484 asset_type,
485 )),
486 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 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
499 serde_yaml::from_str(yaml_content)
500 }
501
502 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
504 serde_yaml::to_string(self)
505 }
506
507 pub fn from_json(json_content: &str) -> Result<Self, serde_json::Error> {
509 serde_json::from_str(json_content)
510 }
511
512 pub fn to_json(&self) -> Result<String, serde_json::Error> {
514 serde_json::to_string(self)
515 }
516
517 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
519 serde_json::to_string_pretty(self)
520 }
521}
522
523fn 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 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)); }
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 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 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 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 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 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 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}