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}
64
65impl AssetType {
66 pub fn extension(&self) -> &'static str {
68 match self {
69 AssetType::Workspace => "yaml",
70 AssetType::Relationships => "yaml",
71 AssetType::Odcs => "odcs.yaml",
72 AssetType::Odps => "odps.yaml",
73 AssetType::Cads => "cads.yaml",
74 AssetType::Bpmn => "bpmn.xml",
75 AssetType::Dmn => "dmn.xml",
76 AssetType::Openapi => "openapi.yaml",
77 }
78 }
79
80 pub fn filename(&self) -> Option<&'static str> {
82 match self {
83 AssetType::Workspace => Some("workspace.yaml"),
84 AssetType::Relationships => Some("relationships.yaml"),
85 _ => None,
86 }
87 }
88
89 pub fn is_workspace_level(&self) -> bool {
91 matches!(self, AssetType::Workspace | AssetType::Relationships)
92 }
93
94 pub fn from_filename(filename: &str) -> Option<Self> {
96 if filename == "workspace.yaml" {
97 Some(AssetType::Workspace)
98 } else if filename == "relationships.yaml" {
99 Some(AssetType::Relationships)
100 } else if filename.ends_with(".odcs.yaml") {
101 Some(AssetType::Odcs)
102 } else if filename.ends_with(".odps.yaml") {
103 Some(AssetType::Odps)
104 } else if filename.ends_with(".cads.yaml") {
105 Some(AssetType::Cads)
106 } else if filename.ends_with(".bpmn.xml") {
107 Some(AssetType::Bpmn)
108 } else if filename.ends_with(".dmn.xml") {
109 Some(AssetType::Dmn)
110 } else if filename.ends_with(".openapi.yaml") || filename.ends_with(".openapi.json") {
111 Some(AssetType::Openapi)
112 } else {
113 None
114 }
115 }
116
117 pub fn supported_extensions() -> &'static [&'static str] {
119 &[
120 "workspace.yaml",
121 "relationships.yaml",
122 ".odcs.yaml",
123 ".odps.yaml",
124 ".cads.yaml",
125 ".bpmn.xml",
126 ".dmn.xml",
127 ".openapi.yaml",
128 ".openapi.json",
129 ]
130 }
131
132 pub fn is_supported_file(filename: &str) -> bool {
134 Self::from_filename(filename).is_some()
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub struct DomainReference {
143 pub id: Uuid,
145 pub name: String,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub description: Option<String>,
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub systems: Vec<SystemReference>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
157pub struct SystemReference {
158 pub id: Uuid,
160 pub name: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub description: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct Workspace {
173 pub id: Uuid,
175 pub name: String,
177 pub owner_id: Uuid,
179 pub created_at: DateTime<Utc>,
181 pub last_modified_at: DateTime<Utc>,
183 #[serde(default)]
185 pub domains: Vec<DomainReference>,
186 #[serde(default)]
188 pub assets: Vec<AssetReference>,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub relationships: Vec<Relationship>,
192}
193
194impl Workspace {
195 pub fn new(name: String, owner_id: Uuid) -> Self {
197 let now = Utc::now();
198 Self {
199 id: Uuid::new_v4(),
200 name,
201 owner_id,
202 created_at: now,
203 last_modified_at: now,
204 domains: Vec::new(),
205 assets: Vec::new(),
206 relationships: Vec::new(),
207 }
208 }
209
210 pub fn with_id(id: Uuid, name: String, owner_id: Uuid) -> Self {
212 let now = Utc::now();
213 Self {
214 id,
215 name,
216 owner_id,
217 created_at: now,
218 last_modified_at: now,
219 domains: Vec::new(),
220 assets: Vec::new(),
221 relationships: Vec::new(),
222 }
223 }
224
225 pub fn add_relationship(&mut self, relationship: Relationship) {
227 if self.relationships.iter().any(|r| r.id == relationship.id) {
229 return;
230 }
231 self.relationships.push(relationship);
232 self.last_modified_at = Utc::now();
233 }
234
235 pub fn remove_relationship(&mut self, relationship_id: Uuid) -> bool {
237 let initial_len = self.relationships.len();
238 self.relationships.retain(|r| r.id != relationship_id);
239 let removed = self.relationships.len() < initial_len;
240 if removed {
241 self.last_modified_at = Utc::now();
242 }
243 removed
244 }
245
246 pub fn get_relationships_for_source(&self, source_table_id: Uuid) -> Vec<&Relationship> {
248 self.relationships
249 .iter()
250 .filter(|r| r.source_table_id == source_table_id)
251 .collect()
252 }
253
254 pub fn get_relationships_for_target(&self, target_table_id: Uuid) -> Vec<&Relationship> {
256 self.relationships
257 .iter()
258 .filter(|r| r.target_table_id == target_table_id)
259 .collect()
260 }
261
262 pub fn add_domain(&mut self, domain_id: Uuid, domain_name: String) {
264 if self.domains.iter().any(|d| d.id == domain_id) {
266 return;
267 }
268 self.domains.push(DomainReference {
269 id: domain_id,
270 name: domain_name,
271 description: None,
272 systems: Vec::new(),
273 });
274 self.last_modified_at = Utc::now();
275 }
276
277 pub fn add_domain_with_description(
279 &mut self,
280 domain_id: Uuid,
281 domain_name: String,
282 description: Option<String>,
283 ) {
284 if self.domains.iter().any(|d| d.id == domain_id) {
285 return;
286 }
287 self.domains.push(DomainReference {
288 id: domain_id,
289 name: domain_name,
290 description,
291 systems: Vec::new(),
292 });
293 self.last_modified_at = Utc::now();
294 }
295
296 pub fn add_system_to_domain(
298 &mut self,
299 domain_name: &str,
300 system_id: Uuid,
301 system_name: String,
302 description: Option<String>,
303 ) -> bool {
304 if let Some(domain) = self.domains.iter_mut().find(|d| d.name == domain_name)
305 && !domain.systems.iter().any(|s| s.id == system_id)
306 {
307 domain.systems.push(SystemReference {
308 id: system_id,
309 name: system_name,
310 description,
311 });
312 self.last_modified_at = Utc::now();
313 return true;
314 }
315 false
316 }
317
318 pub fn remove_domain(&mut self, domain_id: Uuid) -> bool {
320 let initial_len = self.domains.len();
321 self.domains.retain(|d| d.id != domain_id);
322 if let Some(domain) = self.domains.iter().find(|d| d.id == domain_id) {
324 let domain_name = domain.name.clone();
325 self.assets.retain(|a| a.domain != domain_name);
326 }
327 if self.domains.len() != initial_len {
328 self.last_modified_at = Utc::now();
329 true
330 } else {
331 false
332 }
333 }
334
335 pub fn get_domain(&self, domain_id: Uuid) -> Option<&DomainReference> {
337 self.domains.iter().find(|d| d.id == domain_id)
338 }
339
340 pub fn get_domain_by_name(&self, name: &str) -> Option<&DomainReference> {
342 self.domains.iter().find(|d| d.name == name)
343 }
344
345 pub fn add_asset(&mut self, asset: AssetReference) {
347 if self.assets.iter().any(|a| a.id == asset.id) {
349 return;
350 }
351 self.assets.push(asset);
352 self.last_modified_at = Utc::now();
353 }
354
355 pub fn remove_asset(&mut self, asset_id: Uuid) -> bool {
357 let initial_len = self.assets.len();
358 self.assets.retain(|a| a.id != asset_id);
359 if self.assets.len() != initial_len {
360 self.last_modified_at = Utc::now();
361 true
362 } else {
363 false
364 }
365 }
366
367 pub fn get_asset(&self, asset_id: Uuid) -> Option<&AssetReference> {
369 self.assets.iter().find(|a| a.id == asset_id)
370 }
371
372 pub fn get_assets_by_domain(&self, domain_name: &str) -> Vec<&AssetReference> {
374 self.assets
375 .iter()
376 .filter(|a| a.domain == domain_name)
377 .collect()
378 }
379
380 pub fn get_assets_by_type(&self, asset_type: &AssetType) -> Vec<&AssetReference> {
382 self.assets
383 .iter()
384 .filter(|a| &a.asset_type == asset_type)
385 .collect()
386 }
387
388 pub fn generate_asset_filename(&self, asset: &AssetReference) -> String {
391 let mut parts = vec![sanitize_name(&self.name), sanitize_name(&asset.domain)];
392
393 if let Some(ref system) = asset.system {
394 parts.push(sanitize_name(system));
395 }
396
397 parts.push(sanitize_name(&asset.name));
398
399 format!("{}.{}", parts.join("_"), asset.asset_type.extension())
400 }
401
402 pub fn parse_asset_filename(
405 filename: &str,
406 ) -> Option<(String, Option<String>, String, AssetType)> {
407 let (base, asset_type) = if filename.ends_with(".odcs.yaml") {
409 (filename.strip_suffix(".odcs.yaml")?, AssetType::Odcs)
410 } else if filename.ends_with(".odps.yaml") {
411 (filename.strip_suffix(".odps.yaml")?, AssetType::Odps)
412 } else if filename.ends_with(".cads.yaml") {
413 (filename.strip_suffix(".cads.yaml")?, AssetType::Cads)
414 } else if filename.ends_with(".bpmn.xml") {
415 (filename.strip_suffix(".bpmn.xml")?, AssetType::Bpmn)
416 } else if filename.ends_with(".dmn.xml") {
417 (filename.strip_suffix(".dmn.xml")?, AssetType::Dmn)
418 } else if filename.ends_with(".openapi.yaml") {
419 (filename.strip_suffix(".openapi.yaml")?, AssetType::Openapi)
420 } else {
421 return None;
422 };
423
424 let parts: Vec<&str> = base.split('_').collect();
425
426 match parts.len() {
427 3 => Some((parts[1].to_string(), None, parts[2].to_string(), asset_type)),
429 4 => Some((
431 parts[1].to_string(),
432 Some(parts[2].to_string()),
433 parts[3].to_string(),
434 asset_type,
435 )),
436 n if n > 4 => Some((
438 parts[1].to_string(),
439 Some(parts[2].to_string()),
440 parts[3..].join("_"),
441 asset_type,
442 )),
443 _ => None,
444 }
445 }
446
447 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
449 serde_yaml::from_str(yaml_content)
450 }
451
452 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
454 serde_yaml::to_string(self)
455 }
456
457 pub fn from_json(json_content: &str) -> Result<Self, serde_json::Error> {
459 serde_json::from_str(json_content)
460 }
461
462 pub fn to_json(&self) -> Result<String, serde_json::Error> {
464 serde_json::to_string(self)
465 }
466
467 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
469 serde_json::to_string_pretty(self)
470 }
471}
472
473fn sanitize_name(name: &str) -> String {
475 name.chars()
476 .map(|c| match c {
477 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
478 _ => c,
479 })
480 .collect::<String>()
481 .to_lowercase()
482}
483
484impl Default for Workspace {
485 fn default() -> Self {
486 Self::new("Default Workspace".to_string(), Uuid::new_v4())
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn test_workspace_new() {
496 let owner_id = Uuid::new_v4();
497 let workspace = Workspace::new("Test Workspace".to_string(), owner_id);
498
499 assert_eq!(workspace.name, "Test Workspace");
500 assert_eq!(workspace.owner_id, owner_id);
501 assert!(workspace.domains.is_empty());
502 assert!(workspace.assets.is_empty());
503 }
504
505 #[test]
506 fn test_workspace_add_domain() {
507 let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
508 let domain_id = Uuid::new_v4();
509
510 workspace.add_domain(domain_id, "customer-management".to_string());
511
512 assert_eq!(workspace.domains.len(), 1);
513 assert_eq!(workspace.domains[0].id, domain_id);
514 assert_eq!(workspace.domains[0].name, "customer-management");
515
516 workspace.add_domain(domain_id, "customer-management".to_string());
518 assert_eq!(workspace.domains.len(), 1);
519 }
520
521 #[test]
522 fn test_workspace_add_system_to_domain() {
523 let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
524 let domain_id = Uuid::new_v4();
525 let system_id = Uuid::new_v4();
526
527 workspace.add_domain(domain_id, "sales".to_string());
528 let result = workspace.add_system_to_domain(
529 "sales",
530 system_id,
531 "kafka".to_string(),
532 Some("Kafka streaming".to_string()),
533 );
534
535 assert!(result);
536 assert_eq!(workspace.domains[0].systems.len(), 1);
537 assert_eq!(workspace.domains[0].systems[0].name, "kafka");
538 }
539
540 #[test]
541 fn test_workspace_remove_domain() {
542 let mut workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
543 let domain_id = Uuid::new_v4();
544 workspace.add_domain(domain_id, "test-domain".to_string());
545
546 assert!(workspace.remove_domain(domain_id));
547 assert!(workspace.domains.is_empty());
548 assert!(!workspace.remove_domain(domain_id)); }
550
551 #[test]
552 fn test_workspace_add_asset() {
553 let mut workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
554 let asset_id = Uuid::new_v4();
555
556 let asset = AssetReference {
557 id: asset_id,
558 name: "orders".to_string(),
559 domain: "sales".to_string(),
560 system: Some("kafka".to_string()),
561 asset_type: AssetType::Odcs,
562 file_path: None,
563 };
564
565 workspace.add_asset(asset);
566 assert_eq!(workspace.assets.len(), 1);
567 assert_eq!(workspace.assets[0].name, "orders");
568 }
569
570 #[test]
571 fn test_workspace_generate_asset_filename() {
572 let workspace = Workspace::new("enterprise".to_string(), Uuid::new_v4());
573
574 let asset_with_system = AssetReference {
576 id: Uuid::new_v4(),
577 name: "orders".to_string(),
578 domain: "sales".to_string(),
579 system: Some("kafka".to_string()),
580 asset_type: AssetType::Odcs,
581 file_path: None,
582 };
583 assert_eq!(
584 workspace.generate_asset_filename(&asset_with_system),
585 "enterprise_sales_kafka_orders.odcs.yaml"
586 );
587
588 let asset_no_system = AssetReference {
590 id: Uuid::new_v4(),
591 name: "customers".to_string(),
592 domain: "crm".to_string(),
593 system: None,
594 asset_type: AssetType::Odcs,
595 file_path: None,
596 };
597 assert_eq!(
598 workspace.generate_asset_filename(&asset_no_system),
599 "enterprise_crm_customers.odcs.yaml"
600 );
601
602 let odps_asset = AssetReference {
604 id: Uuid::new_v4(),
605 name: "analytics".to_string(),
606 domain: "finance".to_string(),
607 system: None,
608 asset_type: AssetType::Odps,
609 file_path: None,
610 };
611 assert_eq!(
612 workspace.generate_asset_filename(&odps_asset),
613 "enterprise_finance_analytics.odps.yaml"
614 );
615 }
616
617 #[test]
618 fn test_workspace_parse_asset_filename() {
619 let result = Workspace::parse_asset_filename("enterprise_sales_kafka_orders.odcs.yaml");
621 assert!(result.is_some());
622 let (domain, system, name, asset_type) = result.unwrap();
623 assert_eq!(domain, "sales");
624 assert_eq!(system, Some("kafka".to_string()));
625 assert_eq!(name, "orders");
626 assert_eq!(asset_type, AssetType::Odcs);
627
628 let result = Workspace::parse_asset_filename("enterprise_crm_customers.odcs.yaml");
630 assert!(result.is_some());
631 let (domain, system, name, asset_type) = result.unwrap();
632 assert_eq!(domain, "crm");
633 assert_eq!(system, None);
634 assert_eq!(name, "customers");
635 assert_eq!(asset_type, AssetType::Odcs);
636
637 let result = Workspace::parse_asset_filename("workspace_domain_product.odps.yaml");
639 assert!(result.is_some());
640 let (_, _, _, asset_type) = result.unwrap();
641 assert_eq!(asset_type, AssetType::Odps);
642 }
643
644 #[test]
645 fn test_workspace_yaml_roundtrip() {
646 let mut workspace = Workspace::new("Enterprise Models".to_string(), Uuid::new_v4());
647 workspace.add_domain(Uuid::new_v4(), "finance".to_string());
648 workspace.add_domain(Uuid::new_v4(), "risk".to_string());
649 workspace.add_asset(AssetReference {
650 id: Uuid::new_v4(),
651 name: "accounts".to_string(),
652 domain: "finance".to_string(),
653 system: None,
654 asset_type: AssetType::Odcs,
655 file_path: None,
656 });
657
658 let yaml = workspace.to_yaml().unwrap();
659 let parsed = Workspace::from_yaml(&yaml).unwrap();
660
661 assert_eq!(workspace.id, parsed.id);
662 assert_eq!(workspace.name, parsed.name);
663 assert_eq!(workspace.domains.len(), parsed.domains.len());
664 assert_eq!(workspace.assets.len(), parsed.assets.len());
665 }
666
667 #[test]
668 fn test_workspace_json_roundtrip() {
669 let workspace = Workspace::new("Test".to_string(), Uuid::new_v4());
670
671 let json = workspace.to_json().unwrap();
672 let parsed = Workspace::from_json(&json).unwrap();
673
674 assert_eq!(workspace.id, parsed.id);
675 assert_eq!(workspace.name, parsed.name);
676 }
677
678 #[test]
679 fn test_asset_type_extension() {
680 assert_eq!(AssetType::Workspace.extension(), "yaml");
681 assert_eq!(AssetType::Relationships.extension(), "yaml");
682 assert_eq!(AssetType::Odcs.extension(), "odcs.yaml");
683 assert_eq!(AssetType::Odps.extension(), "odps.yaml");
684 assert_eq!(AssetType::Cads.extension(), "cads.yaml");
685 assert_eq!(AssetType::Bpmn.extension(), "bpmn.xml");
686 assert_eq!(AssetType::Dmn.extension(), "dmn.xml");
687 assert_eq!(AssetType::Openapi.extension(), "openapi.yaml");
688 }
689
690 #[test]
691 fn test_asset_type_filename() {
692 assert_eq!(AssetType::Workspace.filename(), Some("workspace.yaml"));
693 assert_eq!(
694 AssetType::Relationships.filename(),
695 Some("relationships.yaml")
696 );
697 assert_eq!(AssetType::Odcs.filename(), None);
698 }
699
700 #[test]
701 fn test_asset_type_from_filename() {
702 assert_eq!(
703 AssetType::from_filename("workspace.yaml"),
704 Some(AssetType::Workspace)
705 );
706 assert_eq!(
707 AssetType::from_filename("relationships.yaml"),
708 Some(AssetType::Relationships)
709 );
710 assert_eq!(
711 AssetType::from_filename("test.odcs.yaml"),
712 Some(AssetType::Odcs)
713 );
714 assert_eq!(
715 AssetType::from_filename("test.odps.yaml"),
716 Some(AssetType::Odps)
717 );
718 assert_eq!(
719 AssetType::from_filename("test.cads.yaml"),
720 Some(AssetType::Cads)
721 );
722 assert_eq!(
723 AssetType::from_filename("test.bpmn.xml"),
724 Some(AssetType::Bpmn)
725 );
726 assert_eq!(
727 AssetType::from_filename("test.dmn.xml"),
728 Some(AssetType::Dmn)
729 );
730 assert_eq!(
731 AssetType::from_filename("test.openapi.yaml"),
732 Some(AssetType::Openapi)
733 );
734 assert_eq!(
735 AssetType::from_filename("test.openapi.json"),
736 Some(AssetType::Openapi)
737 );
738 assert_eq!(AssetType::from_filename("random.txt"), None);
739 assert_eq!(AssetType::from_filename("test.yaml"), None);
740 }
741
742 #[test]
743 fn test_asset_type_is_supported_file() {
744 assert!(AssetType::is_supported_file("workspace.yaml"));
745 assert!(AssetType::is_supported_file("relationships.yaml"));
746 assert!(AssetType::is_supported_file(
747 "enterprise_sales_orders.odcs.yaml"
748 ));
749 assert!(!AssetType::is_supported_file("readme.md"));
750 assert!(!AssetType::is_supported_file("config.json"));
751 }
752
753 #[test]
754 fn test_asset_type_is_workspace_level() {
755 assert!(AssetType::Workspace.is_workspace_level());
756 assert!(AssetType::Relationships.is_workspace_level());
757 assert!(!AssetType::Odcs.is_workspace_level());
758 assert!(!AssetType::Odps.is_workspace_level());
759 }
760
761 #[test]
762 fn test_sanitize_name() {
763 assert_eq!(sanitize_name("Hello World"), "hello-world");
764 assert_eq!(sanitize_name("Test/Path"), "test-path");
765 assert_eq!(sanitize_name("Normal"), "normal");
766 }
767}