1use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use uuid::Uuid;
19
20use super::Relationship;
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct AssetReference {
27 pub id: Uuid,
29 pub name: String,
31 pub domain: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub system: Option<String>,
36 pub asset_type: AssetType,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub file_path: Option<String>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45#[serde(rename_all = "lowercase")]
46pub enum AssetType {
47 Workspace,
49 Relationships,
51 Odcs,
53 Odps,
55 Cads,
57 Bpmn,
59 Dmn,
61 Openapi,
63 Decision,
65 Knowledge,
67 DecisionIndex,
69 KnowledgeIndex,
71}
72
73impl AssetType {
74 pub fn extension(&self) -> &'static str {
76 match self {
77 AssetType::Workspace => "yaml",
78 AssetType::Relationships => "yaml",
79 AssetType::Odcs => "odcs.yaml",
80 AssetType::Odps => "odps.yaml",
81 AssetType::Cads => "cads.yaml",
82 AssetType::Bpmn => "bpmn.xml",
83 AssetType::Dmn => "dmn.xml",
84 AssetType::Openapi => "openapi.yaml",
85 AssetType::Decision => "madr.yaml",
86 AssetType::Knowledge => "kb.yaml",
87 AssetType::DecisionIndex => "yaml",
88 AssetType::KnowledgeIndex => "yaml",
89 }
90 }
91
92 pub fn filename(&self) -> Option<&'static str> {
94 match self {
95 AssetType::Workspace => Some("workspace.yaml"),
96 AssetType::Relationships => Some("relationships.yaml"),
97 AssetType::DecisionIndex => Some("decisions.yaml"),
98 AssetType::KnowledgeIndex => Some("knowledge.yaml"),
99 _ => None,
100 }
101 }
102
103 pub fn is_workspace_level(&self) -> bool {
105 matches!(
106 self,
107 AssetType::Workspace
108 | AssetType::Relationships
109 | AssetType::DecisionIndex
110 | AssetType::KnowledgeIndex
111 )
112 }
113
114 pub fn from_filename(filename: &str) -> Option<Self> {
116 if filename == "workspace.yaml" {
117 Some(AssetType::Workspace)
118 } else if filename == "relationships.yaml" {
119 Some(AssetType::Relationships)
120 } else if filename == "decisions.yaml" {
121 Some(AssetType::DecisionIndex)
122 } else if filename == "knowledge.yaml" {
123 Some(AssetType::KnowledgeIndex)
124 } else if filename.ends_with(".odcs.yaml") {
125 Some(AssetType::Odcs)
126 } else if filename.ends_with(".odps.yaml") {
127 Some(AssetType::Odps)
128 } else if filename.ends_with(".cads.yaml") {
129 Some(AssetType::Cads)
130 } else if filename.ends_with(".madr.yaml") {
131 Some(AssetType::Decision)
132 } else if filename.ends_with(".kb.yaml") {
133 Some(AssetType::Knowledge)
134 } else if filename.ends_with(".bpmn.xml") {
135 Some(AssetType::Bpmn)
136 } else if filename.ends_with(".dmn.xml") {
137 Some(AssetType::Dmn)
138 } else if filename.ends_with(".openapi.yaml") || filename.ends_with(".openapi.json") {
139 Some(AssetType::Openapi)
140 } else {
141 None
142 }
143 }
144
145 pub fn supported_extensions() -> &'static [&'static str] {
147 &[
148 "workspace.yaml",
149 "relationships.yaml",
150 "decisions.yaml",
151 "knowledge.yaml",
152 ".odcs.yaml",
153 ".odps.yaml",
154 ".cads.yaml",
155 ".madr.yaml",
156 ".kb.yaml",
157 ".bpmn.xml",
158 ".dmn.xml",
159 ".openapi.yaml",
160 ".openapi.json",
161 ]
162 }
163
164 pub fn is_supported_file(filename: &str) -> bool {
166 Self::from_filename(filename).is_some()
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174pub struct DomainReference {
175 pub id: Uuid,
177 pub name: String,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub description: Option<String>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub systems: Vec<SystemReference>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
189pub struct SystemReference {
190 pub id: Uuid,
192 pub name: String,
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub description: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
204pub struct Workspace {
205 pub id: Uuid,
207 pub name: String,
209 pub owner_id: Uuid,
211 pub created_at: DateTime<Utc>,
213 pub last_modified_at: DateTime<Utc>,
215 #[serde(default)]
217 pub domains: Vec<DomainReference>,
218 #[serde(default)]
220 pub assets: Vec<AssetReference>,
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub relationships: Vec<Relationship>,
224}
225
226impl Workspace {
227 pub fn new(name: String, owner_id: Uuid) -> Self {
229 let now = Utc::now();
230 Self {
231 id: Uuid::new_v4(),
232 name,
233 owner_id,
234 created_at: now,
235 last_modified_at: now,
236 domains: Vec::new(),
237 assets: Vec::new(),
238 relationships: Vec::new(),
239 }
240 }
241
242 pub fn with_id(id: Uuid, name: String, owner_id: Uuid) -> Self {
244 let now = Utc::now();
245 Self {
246 id,
247 name,
248 owner_id,
249 created_at: now,
250 last_modified_at: now,
251 domains: Vec::new(),
252 assets: Vec::new(),
253 relationships: Vec::new(),
254 }
255 }
256
257 pub fn add_relationship(&mut self, relationship: Relationship) {
259 if self.relationships.iter().any(|r| r.id == relationship.id) {
261 return;
262 }
263 self.relationships.push(relationship);
264 self.last_modified_at = Utc::now();
265 }
266
267 pub fn remove_relationship(&mut self, relationship_id: Uuid) -> bool {
269 let initial_len = self.relationships.len();
270 self.relationships.retain(|r| r.id != relationship_id);
271 let removed = self.relationships.len() < initial_len;
272 if removed {
273 self.last_modified_at = Utc::now();
274 }
275 removed
276 }
277
278 pub fn get_relationships_for_source(&self, source_table_id: Uuid) -> Vec<&Relationship> {
280 self.relationships
281 .iter()
282 .filter(|r| r.source_table_id == source_table_id)
283 .collect()
284 }
285
286 pub fn get_relationships_for_target(&self, target_table_id: Uuid) -> Vec<&Relationship> {
288 self.relationships
289 .iter()
290 .filter(|r| r.target_table_id == target_table_id)
291 .collect()
292 }
293
294 pub fn add_domain(&mut self, domain_id: Uuid, domain_name: String) {
296 if self.domains.iter().any(|d| d.id == domain_id) {
298 return;
299 }
300 self.domains.push(DomainReference {
301 id: domain_id,
302 name: domain_name,
303 description: None,
304 systems: Vec::new(),
305 });
306 self.last_modified_at = Utc::now();
307 }
308
309 pub fn add_domain_with_description(
311 &mut self,
312 domain_id: Uuid,
313 domain_name: String,
314 description: Option<String>,
315 ) {
316 if self.domains.iter().any(|d| d.id == domain_id) {
317 return;
318 }
319 self.domains.push(DomainReference {
320 id: domain_id,
321 name: domain_name,
322 description,
323 systems: Vec::new(),
324 });
325 self.last_modified_at = Utc::now();
326 }
327
328 pub fn add_system_to_domain(
330 &mut self,
331 domain_name: &str,
332 system_id: Uuid,
333 system_name: String,
334 description: Option<String>,
335 ) -> bool {
336 if let Some(domain) = self.domains.iter_mut().find(|d| d.name == domain_name)
337 && !domain.systems.iter().any(|s| s.id == system_id)
338 {
339 domain.systems.push(SystemReference {
340 id: system_id,
341 name: system_name,
342 description,
343 });
344 self.last_modified_at = Utc::now();
345 return true;
346 }
347 false
348 }
349
350 pub fn remove_domain(&mut self, domain_id: Uuid) -> bool {
352 let initial_len = self.domains.len();
353 self.domains.retain(|d| d.id != domain_id);
354 if let Some(domain) = self.domains.iter().find(|d| d.id == domain_id) {
356 let domain_name = domain.name.clone();
357 self.assets.retain(|a| a.domain != domain_name);
358 }
359 if self.domains.len() != initial_len {
360 self.last_modified_at = Utc::now();
361 true
362 } else {
363 false
364 }
365 }
366
367 pub fn get_domain(&self, domain_id: Uuid) -> Option<&DomainReference> {
369 self.domains.iter().find(|d| d.id == domain_id)
370 }
371
372 pub fn get_domain_by_name(&self, name: &str) -> Option<&DomainReference> {
374 self.domains.iter().find(|d| d.name == name)
375 }
376
377 pub fn add_asset(&mut self, asset: AssetReference) {
379 if self.assets.iter().any(|a| a.id == asset.id) {
381 return;
382 }
383 self.assets.push(asset);
384 self.last_modified_at = Utc::now();
385 }
386
387 pub fn remove_asset(&mut self, asset_id: Uuid) -> bool {
389 let initial_len = self.assets.len();
390 self.assets.retain(|a| a.id != asset_id);
391 if self.assets.len() != initial_len {
392 self.last_modified_at = Utc::now();
393 true
394 } else {
395 false
396 }
397 }
398
399 pub fn get_asset(&self, asset_id: Uuid) -> Option<&AssetReference> {
401 self.assets.iter().find(|a| a.id == asset_id)
402 }
403
404 pub fn get_assets_by_domain(&self, domain_name: &str) -> Vec<&AssetReference> {
406 self.assets
407 .iter()
408 .filter(|a| a.domain == domain_name)
409 .collect()
410 }
411
412 pub fn get_assets_by_type(&self, asset_type: &AssetType) -> Vec<&AssetReference> {
414 self.assets
415 .iter()
416 .filter(|a| &a.asset_type == asset_type)
417 .collect()
418 }
419
420 pub fn generate_asset_filename(&self, asset: &AssetReference) -> String {
423 let mut parts = vec![sanitize_name(&self.name), sanitize_name(&asset.domain)];
424
425 if let Some(ref system) = asset.system {
426 parts.push(sanitize_name(system));
427 }
428
429 parts.push(sanitize_name(&asset.name));
430
431 format!("{}.{}", parts.join("_"), asset.asset_type.extension())
432 }
433
434 pub fn parse_asset_filename(
437 filename: &str,
438 ) -> Option<(String, Option<String>, String, AssetType)> {
439 let (base, asset_type) = if filename.ends_with(".odcs.yaml") {
441 (filename.strip_suffix(".odcs.yaml")?, AssetType::Odcs)
442 } else if filename.ends_with(".odps.yaml") {
443 (filename.strip_suffix(".odps.yaml")?, AssetType::Odps)
444 } else if filename.ends_with(".cads.yaml") {
445 (filename.strip_suffix(".cads.yaml")?, AssetType::Cads)
446 } else if filename.ends_with(".bpmn.xml") {
447 (filename.strip_suffix(".bpmn.xml")?, AssetType::Bpmn)
448 } else if filename.ends_with(".dmn.xml") {
449 (filename.strip_suffix(".dmn.xml")?, AssetType::Dmn)
450 } else if filename.ends_with(".openapi.yaml") {
451 (filename.strip_suffix(".openapi.yaml")?, AssetType::Openapi)
452 } else {
453 return None;
454 };
455
456 let parts: Vec<&str> = base.split('_').collect();
457
458 match parts.len() {
459 3 => Some((parts[1].to_string(), None, parts[2].to_string(), asset_type)),
461 4 => Some((
463 parts[1].to_string(),
464 Some(parts[2].to_string()),
465 parts[3].to_string(),
466 asset_type,
467 )),
468 n if n > 4 => Some((
470 parts[1].to_string(),
471 Some(parts[2].to_string()),
472 parts[3..].join("_"),
473 asset_type,
474 )),
475 _ => None,
476 }
477 }
478
479 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
481 serde_yaml::from_str(yaml_content)
482 }
483
484 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
486 serde_yaml::to_string(self)
487 }
488
489 pub fn from_json(json_content: &str) -> Result<Self, serde_json::Error> {
491 serde_json::from_str(json_content)
492 }
493
494 pub fn to_json(&self) -> Result<String, serde_json::Error> {
496 serde_json::to_string(self)
497 }
498
499 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
501 serde_json::to_string_pretty(self)
502 }
503}
504
505fn sanitize_name(name: &str) -> String {
507 name.chars()
508 .map(|c| match c {
509 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
510 _ => c,
511 })
512 .collect::<String>()
513 .to_lowercase()
514}
515
516impl Default for Workspace {
517 fn default() -> Self {
518 Self::new("Default Workspace".to_string(), Uuid::new_v4())
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn test_workspace_new() {
528 let owner_id = Uuid::new_v4();
529 let workspace = Workspace::new("Test Workspace".to_string(), owner_id);
530
531 assert_eq!(workspace.name, "Test Workspace");
532 assert_eq!(workspace.owner_id, owner_id);
533 assert!(workspace.domains.is_empty());
534 assert!(workspace.assets.is_empty());
535 }
536
537 #[test]
538 fn test_workspace_add_domain() {
539 let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
540 let domain_id = Uuid::new_v4();
541
542 workspace.add_domain(domain_id, "customer-management".to_string());
543
544 assert_eq!(workspace.domains.len(), 1);
545 assert_eq!(workspace.domains[0].id, domain_id);
546 assert_eq!(workspace.domains[0].name, "customer-management");
547
548 workspace.add_domain(domain_id, "customer-management".to_string());
550 assert_eq!(workspace.domains.len(), 1);
551 }
552
553 #[test]
554 fn test_workspace_add_system_to_domain() {
555 let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
556 let domain_id = Uuid::new_v4();
557 let system_id = Uuid::new_v4();
558
559 workspace.add_domain(domain_id, "sales".to_string());
560 let result = workspace.add_system_to_domain(
561 "sales",
562 system_id,
563 "kafka".to_string(),
564 Some("Kafka streaming".to_string()),
565 );
566
567 assert!(result);
568 assert_eq!(workspace.domains[0].systems.len(), 1);
569 assert_eq!(workspace.domains[0].systems[0].name, "kafka");
570 }
571
572 #[test]
573 fn test_workspace_remove_domain() {
574 let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
575 let domain_id = Uuid::new_v4();
576 workspace.add_domain(domain_id, "test-domain".to_string());
577
578 assert!(workspace.remove_domain(domain_id));
579 assert!(workspace.domains.is_empty());
580 assert!(!workspace.remove_domain(domain_id)); }
582
583 #[test]
584 fn test_workspace_add_asset() {
585 let mut workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
586 let asset_id = Uuid::new_v4();
587
588 let asset = AssetReference {
589 id: asset_id,
590 name: "orders".to_string(),
591 domain: "sales".to_string(),
592 system: Some("kafka".to_string()),
593 asset_type: AssetType::Odcs,
594 file_path: None,
595 };
596
597 workspace.add_asset(asset);
598 assert_eq!(workspace.assets.len(), 1);
599 assert_eq!(workspace.assets[0].name, "orders");
600 }
601
602 #[test]
603 fn test_workspace_generate_asset_filename() {
604 let workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
605
606 let asset_with_system = AssetReference {
608 id: Uuid::new_v4(),
609 name: "orders".to_string(),
610 domain: "sales".to_string(),
611 system: Some("kafka".to_string()),
612 asset_type: AssetType::Odcs,
613 file_path: None,
614 };
615 assert_eq!(
616 workspace.generate_asset_filename(&asset_with_system),
617 "enterprise_sales_kafka_orders.odcs.yaml"
618 );
619
620 let asset_no_system = AssetReference {
622 id: Uuid::new_v4(),
623 name: "customers".to_string(),
624 domain: "crm".to_string(),
625 system: None,
626 asset_type: AssetType::Odcs,
627 file_path: None,
628 };
629 assert_eq!(
630 workspace.generate_asset_filename(&asset_no_system),
631 "enterprise_crm_customers.odcs.yaml"
632 );
633
634 let odps_asset = AssetReference {
636 id: Uuid::new_v4(),
637 name: "analytics".to_string(),
638 domain: "finance".to_string(),
639 system: None,
640 asset_type: AssetType::Odps,
641 file_path: None,
642 };
643 assert_eq!(
644 workspace.generate_asset_filename(&odps_asset),
645 "enterprise_finance_analytics.odps.yaml"
646 );
647 }
648
649 #[test]
650 fn test_workspace_parse_asset_filename() {
651 let result = Workspace::parse_asset_filename("enterprise_sales_kafka_orders.odcs.yaml");
653 assert!(result.is_some());
654 let (domain, system, name, asset_type) = result.unwrap();
655 assert_eq!(domain, "sales");
656 assert_eq!(system, Some("kafka".to_string()));
657 assert_eq!(name, "orders");
658 assert_eq!(asset_type, AssetType::Odcs);
659
660 let result = Workspace::parse_asset_filename("enterprise_crm_customers.odcs.yaml");
662 assert!(result.is_some());
663 let (domain, system, name, asset_type) = result.unwrap();
664 assert_eq!(domain, "crm");
665 assert_eq!(system, None);
666 assert_eq!(name, "customers");
667 assert_eq!(asset_type, AssetType::Odcs);
668
669 let result = Workspace::parse_asset_filename("workspace_domain_product.odps.yaml");
671 assert!(result.is_some());
672 let (_, _, _, asset_type) = result.unwrap();
673 assert_eq!(asset_type, AssetType::Odps);
674 }
675
676 #[test]
677 fn test_workspace_yaml_roundtrip() {
678 let mut workspace = Workspace::new("Enterprise Models".to_string(), Uuid::new_v4());
679 workspace.add_domain(Uuid::new_v4(), "finance".to_string());
680 workspace.add_domain(Uuid::new_v4(), "risk".to_string());
681 workspace.add_asset(AssetReference {
682 id: Uuid::new_v4(),
683 name: "accounts".to_string(),
684 domain: "finance".to_string(),
685 system: None,
686 asset_type: AssetType::Odcs,
687 file_path: None,
688 });
689
690 let yaml = workspace.to_yaml().unwrap();
691 let parsed = Workspace::from_yaml(&yaml).unwrap();
692
693 assert_eq!(workspace.id, parsed.id);
694 assert_eq!(workspace.name, parsed.name);
695 assert_eq!(workspace.domains.len(), parsed.domains.len());
696 assert_eq!(workspace.assets.len(), parsed.assets.len());
697 }
698
699 #[test]
700 fn test_workspace_json_roundtrip() {
701 let workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
702
703 let json = workspace.to_json().unwrap();
704 let parsed = Workspace::from_json(&json).unwrap();
705
706 assert_eq!(workspace.id, parsed.id);
707 assert_eq!(workspace.name, parsed.name);
708 }
709
710 #[test]
711 fn test_asset_type_extension() {
712 assert_eq!(AssetType::Workspace.extension(), "yaml");
713 assert_eq!(AssetType::Relationships.extension(), "yaml");
714 assert_eq!(AssetType::Odcs.extension(), "odcs.yaml");
715 assert_eq!(AssetType::Odps.extension(), "odps.yaml");
716 assert_eq!(AssetType::Cads.extension(), "cads.yaml");
717 assert_eq!(AssetType::Bpmn.extension(), "bpmn.xml");
718 assert_eq!(AssetType::Dmn.extension(), "dmn.xml");
719 assert_eq!(AssetType::Openapi.extension(), "openapi.yaml");
720 }
721
722 #[test]
723 fn test_asset_type_filename() {
724 assert_eq!(AssetType::Workspace.filename(), Some("workspace.yaml"));
725 assert_eq!(
726 AssetType::Relationships.filename(),
727 Some("relationships.yaml")
728 );
729 assert_eq!(AssetType::Odcs.filename(), None);
730 }
731
732 #[test]
733 fn test_asset_type_from_filename() {
734 assert_eq!(
735 AssetType::from_filename("workspace.yaml"),
736 Some(AssetType::Workspace)
737 );
738 assert_eq!(
739 AssetType::from_filename("relationships.yaml"),
740 Some(AssetType::Relationships)
741 );
742 assert_eq!(
743 AssetType::from_filename("test.odcs.yaml"),
744 Some(AssetType::Odcs)
745 );
746 assert_eq!(
747 AssetType::from_filename("test.odps.yaml"),
748 Some(AssetType::Odps)
749 );
750 assert_eq!(
751 AssetType::from_filename("test.cads.yaml"),
752 Some(AssetType::Cads)
753 );
754 assert_eq!(
755 AssetType::from_filename("test.bpmn.xml"),
756 Some(AssetType::Bpmn)
757 );
758 assert_eq!(
759 AssetType::from_filename("test.dmn.xml"),
760 Some(AssetType::Dmn)
761 );
762 assert_eq!(
763 AssetType::from_filename("test.openapi.yaml"),
764 Some(AssetType::Openapi)
765 );
766 assert_eq!(
767 AssetType::from_filename("test.openapi.json"),
768 Some(AssetType::Openapi)
769 );
770 assert_eq!(AssetType::from_filename("random.txt"), None);
771 assert_eq!(AssetType::from_filename("test.yaml"), None);
772 }
773
774 #[test]
775 fn test_asset_type_is_supported_file() {
776 assert!(AssetType::is_supported_file("workspace.yaml"));
777 assert!(AssetType::is_supported_file("relationships.yaml"));
778 assert!(AssetType::is_supported_file(
779 "enterprise_sales_orders.odcs.yaml"
780 ));
781 assert!(!AssetType::is_supported_file("readme.md"));
782 assert!(!AssetType::is_supported_file("config.json"));
783 }
784
785 #[test]
786 fn test_asset_type_is_workspace_level() {
787 assert!(AssetType::Workspace.is_workspace_level());
788 assert!(AssetType::Relationships.is_workspace_level());
789 assert!(!AssetType::Odcs.is_workspace_level());
790 assert!(!AssetType::Odps.is_workspace_level());
791 }
792
793 #[test]
794 fn test_sanitize_name() {
795 assert_eq!(sanitize_name("Hello World"), "hello-world");
796 assert_eq!(sanitize_name("Test/Path"), "test-path");
797 assert_eq!(sanitize_name("Normal"), "normal");
798 }
799}