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