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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub table_ids: Vec<Uuid>,
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
204 pub asset_ids: Vec<Uuid>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212pub struct Workspace {
213 pub id: Uuid,
215 pub name: String,
217 pub owner_id: Uuid,
219 pub created_at: DateTime<Utc>,
221 pub last_modified_at: DateTime<Utc>,
223 #[serde(default)]
225 pub domains: Vec<DomainReference>,
226 #[serde(default)]
228 pub assets: Vec<AssetReference>,
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub relationships: Vec<Relationship>,
232}
233
234impl Workspace {
235 pub fn new(name: String, owner_id: Uuid) -> Self {
237 let now = Utc::now();
238 Self {
239 id: Uuid::new_v4(),
240 name,
241 owner_id,
242 created_at: now,
243 last_modified_at: now,
244 domains: Vec::new(),
245 assets: Vec::new(),
246 relationships: Vec::new(),
247 }
248 }
249
250 pub fn with_id(id: Uuid, name: String, owner_id: Uuid) -> Self {
252 let now = Utc::now();
253 Self {
254 id,
255 name,
256 owner_id,
257 created_at: now,
258 last_modified_at: now,
259 domains: Vec::new(),
260 assets: Vec::new(),
261 relationships: Vec::new(),
262 }
263 }
264
265 pub fn add_relationship(&mut self, relationship: Relationship) {
267 if self.relationships.iter().any(|r| r.id == relationship.id) {
269 return;
270 }
271 self.relationships.push(relationship);
272 self.last_modified_at = Utc::now();
273 }
274
275 pub fn remove_relationship(&mut self, relationship_id: Uuid) -> bool {
277 let initial_len = self.relationships.len();
278 self.relationships.retain(|r| r.id != relationship_id);
279 let removed = self.relationships.len() < initial_len;
280 if removed {
281 self.last_modified_at = Utc::now();
282 }
283 removed
284 }
285
286 pub fn get_relationships_for_source(&self, source_table_id: Uuid) -> Vec<&Relationship> {
288 self.relationships
289 .iter()
290 .filter(|r| r.source_table_id == source_table_id)
291 .collect()
292 }
293
294 pub fn get_relationships_for_target(&self, target_table_id: Uuid) -> Vec<&Relationship> {
296 self.relationships
297 .iter()
298 .filter(|r| r.target_table_id == target_table_id)
299 .collect()
300 }
301
302 pub fn add_domain(&mut self, domain_id: Uuid, domain_name: String) {
304 if self.domains.iter().any(|d| d.id == domain_id) {
306 return;
307 }
308 self.domains.push(DomainReference {
309 id: domain_id,
310 name: domain_name,
311 description: None,
312 systems: Vec::new(),
313 });
314 self.last_modified_at = Utc::now();
315 }
316
317 pub fn add_domain_with_description(
319 &mut self,
320 domain_id: Uuid,
321 domain_name: String,
322 description: Option<String>,
323 ) {
324 if self.domains.iter().any(|d| d.id == domain_id) {
325 return;
326 }
327 self.domains.push(DomainReference {
328 id: domain_id,
329 name: domain_name,
330 description,
331 systems: Vec::new(),
332 });
333 self.last_modified_at = Utc::now();
334 }
335
336 pub fn add_system_to_domain(
338 &mut self,
339 domain_name: &str,
340 system_id: Uuid,
341 system_name: String,
342 description: Option<String>,
343 ) -> bool {
344 if let Some(domain) = self.domains.iter_mut().find(|d| d.name == domain_name)
345 && !domain.systems.iter().any(|s| s.id == system_id)
346 {
347 domain.systems.push(SystemReference {
348 id: system_id,
349 name: system_name,
350 description,
351 table_ids: Vec::new(),
352 asset_ids: Vec::new(),
353 });
354 self.last_modified_at = Utc::now();
355 return true;
356 }
357 false
358 }
359
360 pub fn remove_domain(&mut self, domain_id: Uuid) -> bool {
362 let initial_len = self.domains.len();
363 self.domains.retain(|d| d.id != domain_id);
364 if let Some(domain) = self.domains.iter().find(|d| d.id == domain_id) {
366 let domain_name = domain.name.clone();
367 self.assets.retain(|a| a.domain != domain_name);
368 }
369 if self.domains.len() != initial_len {
370 self.last_modified_at = Utc::now();
371 true
372 } else {
373 false
374 }
375 }
376
377 pub fn get_domain(&self, domain_id: Uuid) -> Option<&DomainReference> {
379 self.domains.iter().find(|d| d.id == domain_id)
380 }
381
382 pub fn get_domain_by_name(&self, name: &str) -> Option<&DomainReference> {
384 self.domains.iter().find(|d| d.name == name)
385 }
386
387 pub fn add_asset(&mut self, asset: AssetReference) {
389 if self.assets.iter().any(|a| a.id == asset.id) {
391 return;
392 }
393 self.assets.push(asset);
394 self.last_modified_at = Utc::now();
395 }
396
397 pub fn remove_asset(&mut self, asset_id: Uuid) -> bool {
399 let initial_len = self.assets.len();
400 self.assets.retain(|a| a.id != asset_id);
401 if self.assets.len() != initial_len {
402 self.last_modified_at = Utc::now();
403 true
404 } else {
405 false
406 }
407 }
408
409 pub fn get_asset(&self, asset_id: Uuid) -> Option<&AssetReference> {
411 self.assets.iter().find(|a| a.id == asset_id)
412 }
413
414 pub fn get_assets_by_domain(&self, domain_name: &str) -> Vec<&AssetReference> {
416 self.assets
417 .iter()
418 .filter(|a| a.domain == domain_name)
419 .collect()
420 }
421
422 pub fn get_assets_by_type(&self, asset_type: &AssetType) -> Vec<&AssetReference> {
424 self.assets
425 .iter()
426 .filter(|a| &a.asset_type == asset_type)
427 .collect()
428 }
429
430 pub fn generate_asset_filename(&self, asset: &AssetReference) -> String {
433 let mut parts = vec![sanitize_name(&self.name), sanitize_name(&asset.domain)];
434
435 if let Some(ref system) = asset.system {
436 parts.push(sanitize_name(system));
437 }
438
439 parts.push(sanitize_name(&asset.name));
440
441 format!("{}.{}", parts.join("_"), asset.asset_type.extension())
442 }
443
444 pub fn parse_asset_filename(
447 filename: &str,
448 ) -> Option<(String, Option<String>, String, AssetType)> {
449 let (base, asset_type) = if filename.ends_with(".odcs.yaml") {
451 (filename.strip_suffix(".odcs.yaml")?, AssetType::Odcs)
452 } else if filename.ends_with(".odps.yaml") {
453 (filename.strip_suffix(".odps.yaml")?, AssetType::Odps)
454 } else if filename.ends_with(".cads.yaml") {
455 (filename.strip_suffix(".cads.yaml")?, AssetType::Cads)
456 } else if filename.ends_with(".bpmn.xml") {
457 (filename.strip_suffix(".bpmn.xml")?, AssetType::Bpmn)
458 } else if filename.ends_with(".dmn.xml") {
459 (filename.strip_suffix(".dmn.xml")?, AssetType::Dmn)
460 } else if filename.ends_with(".openapi.yaml") {
461 (filename.strip_suffix(".openapi.yaml")?, AssetType::Openapi)
462 } else {
463 return None;
464 };
465
466 let parts: Vec<&str> = base.split('_').collect();
467
468 match parts.len() {
469 3 => Some((parts[1].to_string(), None, parts[2].to_string(), asset_type)),
471 4 => Some((
473 parts[1].to_string(),
474 Some(parts[2].to_string()),
475 parts[3].to_string(),
476 asset_type,
477 )),
478 n if n > 4 => Some((
480 parts[1].to_string(),
481 Some(parts[2].to_string()),
482 parts[3..].join("_"),
483 asset_type,
484 )),
485 _ => None,
486 }
487 }
488
489 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
491 serde_yaml::from_str(yaml_content)
492 }
493
494 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
496 serde_yaml::to_string(self)
497 }
498
499 pub fn from_json(json_content: &str) -> Result<Self, serde_json::Error> {
501 serde_json::from_str(json_content)
502 }
503
504 pub fn to_json(&self) -> Result<String, serde_json::Error> {
506 serde_json::to_string(self)
507 }
508
509 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
511 serde_json::to_string_pretty(self)
512 }
513}
514
515fn sanitize_name(name: &str) -> String {
517 name.chars()
518 .map(|c| match c {
519 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
520 _ => c,
521 })
522 .collect::<String>()
523 .to_lowercase()
524}
525
526impl Default for Workspace {
527 fn default() -> Self {
528 Self::new("Default Workspace".to_string(), Uuid::new_v4())
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn test_workspace_new() {
538 let owner_id = Uuid::new_v4();
539 let workspace = Workspace::new("Test Workspace".to_string(), owner_id);
540
541 assert_eq!(workspace.name, "Test Workspace");
542 assert_eq!(workspace.owner_id, owner_id);
543 assert!(workspace.domains.is_empty());
544 assert!(workspace.assets.is_empty());
545 }
546
547 #[test]
548 fn test_workspace_add_domain() {
549 let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
550 let domain_id = Uuid::new_v4();
551
552 workspace.add_domain(domain_id, "customer-management".to_string());
553
554 assert_eq!(workspace.domains.len(), 1);
555 assert_eq!(workspace.domains[0].id, domain_id);
556 assert_eq!(workspace.domains[0].name, "customer-management");
557
558 workspace.add_domain(domain_id, "customer-management".to_string());
560 assert_eq!(workspace.domains.len(), 1);
561 }
562
563 #[test]
564 fn test_workspace_add_system_to_domain() {
565 let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
566 let domain_id = Uuid::new_v4();
567 let system_id = Uuid::new_v4();
568
569 workspace.add_domain(domain_id, "sales".to_string());
570 let result = workspace.add_system_to_domain(
571 "sales",
572 system_id,
573 "kafka".to_string(),
574 Some("Kafka streaming".to_string()),
575 );
576
577 assert!(result);
578 assert_eq!(workspace.domains[0].systems.len(), 1);
579 assert_eq!(workspace.domains[0].systems[0].name, "kafka");
580 }
581
582 #[test]
583 fn test_workspace_remove_domain() {
584 let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
585 let domain_id = Uuid::new_v4();
586 workspace.add_domain(domain_id, "test-domain".to_string());
587
588 assert!(workspace.remove_domain(domain_id));
589 assert!(workspace.domains.is_empty());
590 assert!(!workspace.remove_domain(domain_id)); }
592
593 #[test]
594 fn test_workspace_add_asset() {
595 let mut workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
596 let asset_id = Uuid::new_v4();
597
598 let asset = AssetReference {
599 id: asset_id,
600 name: "orders".to_string(),
601 domain: "sales".to_string(),
602 system: Some("kafka".to_string()),
603 asset_type: AssetType::Odcs,
604 file_path: None,
605 };
606
607 workspace.add_asset(asset);
608 assert_eq!(workspace.assets.len(), 1);
609 assert_eq!(workspace.assets[0].name, "orders");
610 }
611
612 #[test]
613 fn test_workspace_generate_asset_filename() {
614 let workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
615
616 let asset_with_system = AssetReference {
618 id: Uuid::new_v4(),
619 name: "orders".to_string(),
620 domain: "sales".to_string(),
621 system: Some("kafka".to_string()),
622 asset_type: AssetType::Odcs,
623 file_path: None,
624 };
625 assert_eq!(
626 workspace.generate_asset_filename(&asset_with_system),
627 "enterprise_sales_kafka_orders.odcs.yaml"
628 );
629
630 let asset_no_system = AssetReference {
632 id: Uuid::new_v4(),
633 name: "customers".to_string(),
634 domain: "crm".to_string(),
635 system: None,
636 asset_type: AssetType::Odcs,
637 file_path: None,
638 };
639 assert_eq!(
640 workspace.generate_asset_filename(&asset_no_system),
641 "enterprise_crm_customers.odcs.yaml"
642 );
643
644 let odps_asset = AssetReference {
646 id: Uuid::new_v4(),
647 name: "analytics".to_string(),
648 domain: "finance".to_string(),
649 system: None,
650 asset_type: AssetType::Odps,
651 file_path: None,
652 };
653 assert_eq!(
654 workspace.generate_asset_filename(&odps_asset),
655 "enterprise_finance_analytics.odps.yaml"
656 );
657 }
658
659 #[test]
660 fn test_workspace_parse_asset_filename() {
661 let result = Workspace::parse_asset_filename("enterprise_sales_kafka_orders.odcs.yaml");
663 assert!(result.is_some());
664 let (domain, system, name, asset_type) = result.unwrap();
665 assert_eq!(domain, "sales");
666 assert_eq!(system, Some("kafka".to_string()));
667 assert_eq!(name, "orders");
668 assert_eq!(asset_type, AssetType::Odcs);
669
670 let result = Workspace::parse_asset_filename("enterprise_crm_customers.odcs.yaml");
672 assert!(result.is_some());
673 let (domain, system, name, asset_type) = result.unwrap();
674 assert_eq!(domain, "crm");
675 assert_eq!(system, None);
676 assert_eq!(name, "customers");
677 assert_eq!(asset_type, AssetType::Odcs);
678
679 let result = Workspace::parse_asset_filename("workspace_domain_product.odps.yaml");
681 assert!(result.is_some());
682 let (_, _, _, asset_type) = result.unwrap();
683 assert_eq!(asset_type, AssetType::Odps);
684 }
685
686 #[test]
687 fn test_workspace_yaml_roundtrip() {
688 let mut workspace = Workspace::new("Enterprise Models".to_string(), Uuid::new_v4());
689 workspace.add_domain(Uuid::new_v4(), "finance".to_string());
690 workspace.add_domain(Uuid::new_v4(), "risk".to_string());
691 workspace.add_asset(AssetReference {
692 id: Uuid::new_v4(),
693 name: "accounts".to_string(),
694 domain: "finance".to_string(),
695 system: None,
696 asset_type: AssetType::Odcs,
697 file_path: None,
698 });
699
700 let yaml = workspace.to_yaml().unwrap();
701 let parsed = Workspace::from_yaml(&yaml).unwrap();
702
703 assert_eq!(workspace.id, parsed.id);
704 assert_eq!(workspace.name, parsed.name);
705 assert_eq!(workspace.domains.len(), parsed.domains.len());
706 assert_eq!(workspace.assets.len(), parsed.assets.len());
707 }
708
709 #[test]
710 fn test_workspace_json_roundtrip() {
711 let workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
712
713 let json = workspace.to_json().unwrap();
714 let parsed = Workspace::from_json(&json).unwrap();
715
716 assert_eq!(workspace.id, parsed.id);
717 assert_eq!(workspace.name, parsed.name);
718 }
719
720 #[test]
721 fn test_asset_type_extension() {
722 assert_eq!(AssetType::Workspace.extension(), "yaml");
723 assert_eq!(AssetType::Relationships.extension(), "yaml");
724 assert_eq!(AssetType::Odcs.extension(), "odcs.yaml");
725 assert_eq!(AssetType::Odps.extension(), "odps.yaml");
726 assert_eq!(AssetType::Cads.extension(), "cads.yaml");
727 assert_eq!(AssetType::Bpmn.extension(), "bpmn.xml");
728 assert_eq!(AssetType::Dmn.extension(), "dmn.xml");
729 assert_eq!(AssetType::Openapi.extension(), "openapi.yaml");
730 }
731
732 #[test]
733 fn test_asset_type_filename() {
734 assert_eq!(AssetType::Workspace.filename(), Some("workspace.yaml"));
735 assert_eq!(
736 AssetType::Relationships.filename(),
737 Some("relationships.yaml")
738 );
739 assert_eq!(AssetType::Odcs.filename(), None);
740 }
741
742 #[test]
743 fn test_asset_type_from_filename() {
744 assert_eq!(
745 AssetType::from_filename("workspace.yaml"),
746 Some(AssetType::Workspace)
747 );
748 assert_eq!(
749 AssetType::from_filename("relationships.yaml"),
750 Some(AssetType::Relationships)
751 );
752 assert_eq!(
753 AssetType::from_filename("test.odcs.yaml"),
754 Some(AssetType::Odcs)
755 );
756 assert_eq!(
757 AssetType::from_filename("test.odps.yaml"),
758 Some(AssetType::Odps)
759 );
760 assert_eq!(
761 AssetType::from_filename("test.cads.yaml"),
762 Some(AssetType::Cads)
763 );
764 assert_eq!(
765 AssetType::from_filename("test.bpmn.xml"),
766 Some(AssetType::Bpmn)
767 );
768 assert_eq!(
769 AssetType::from_filename("test.dmn.xml"),
770 Some(AssetType::Dmn)
771 );
772 assert_eq!(
773 AssetType::from_filename("test.openapi.yaml"),
774 Some(AssetType::Openapi)
775 );
776 assert_eq!(
777 AssetType::from_filename("test.openapi.json"),
778 Some(AssetType::Openapi)
779 );
780 assert_eq!(AssetType::from_filename("random.txt"), None);
781 assert_eq!(AssetType::from_filename("test.yaml"), None);
782 }
783
784 #[test]
785 fn test_asset_type_is_supported_file() {
786 assert!(AssetType::is_supported_file("workspace.yaml"));
787 assert!(AssetType::is_supported_file("relationships.yaml"));
788 assert!(AssetType::is_supported_file(
789 "enterprise_sales_orders.odcs.yaml"
790 ));
791 assert!(!AssetType::is_supported_file("readme.md"));
792 assert!(!AssetType::is_supported_file("config.json"));
793 }
794
795 #[test]
796 fn test_asset_type_is_workspace_level() {
797 assert!(AssetType::Workspace.is_workspace_level());
798 assert!(AssetType::Relationships.is_workspace_level());
799 assert!(!AssetType::Odcs.is_workspace_level());
800 assert!(!AssetType::Odps.is_workspace_level());
801 }
802
803 #[test]
804 fn test_sanitize_name() {
805 assert_eq!(sanitize_name("Hello World"), "hello-world");
806 assert_eq!(sanitize_name("Test/Path"), "test-path");
807 assert_eq!(sanitize_name("Normal"), "normal");
808 }
809}