1use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34use uuid::Uuid;
35
36use super::Tag;
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "lowercase")]
43pub enum DecisionStatus {
44 Draft,
46 #[default]
48 Proposed,
49 Accepted,
51 Rejected,
53 Superseded,
55 Deprecated,
57}
58
59impl std::fmt::Display for DecisionStatus {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 DecisionStatus::Draft => write!(f, "Draft"),
63 DecisionStatus::Proposed => write!(f, "Proposed"),
64 DecisionStatus::Accepted => write!(f, "Accepted"),
65 DecisionStatus::Rejected => write!(f, "Rejected"),
66 DecisionStatus::Superseded => write!(f, "Superseded"),
67 DecisionStatus::Deprecated => write!(f, "Deprecated"),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "lowercase")]
77pub enum DecisionCategory {
78 #[default]
80 Architecture,
81 Technology,
83 Process,
85 Security,
87 Data,
89 Integration,
91 DataDesign,
93 Workflow,
95 Model,
97 Governance,
99 Performance,
101 Compliance,
103 Infrastructure,
105 Tooling,
107}
108
109impl std::fmt::Display for DecisionCategory {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 DecisionCategory::Architecture => write!(f, "Architecture"),
113 DecisionCategory::Technology => write!(f, "Technology"),
114 DecisionCategory::Process => write!(f, "Process"),
115 DecisionCategory::Security => write!(f, "Security"),
116 DecisionCategory::Data => write!(f, "Data"),
117 DecisionCategory::Integration => write!(f, "Integration"),
118 DecisionCategory::DataDesign => write!(f, "Data Design"),
119 DecisionCategory::Workflow => write!(f, "Workflow"),
120 DecisionCategory::Model => write!(f, "Model"),
121 DecisionCategory::Governance => write!(f, "Governance"),
122 DecisionCategory::Performance => write!(f, "Performance"),
123 DecisionCategory::Compliance => write!(f, "Compliance"),
124 DecisionCategory::Infrastructure => write!(f, "Infrastructure"),
125 DecisionCategory::Tooling => write!(f, "Tooling"),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(rename_all = "lowercase")]
133pub enum DriverPriority {
134 High,
135 #[default]
136 Medium,
137 Low,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub struct DecisionDriver {
143 pub description: String,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub priority: Option<DriverPriority>,
148}
149
150impl DecisionDriver {
151 pub fn new(description: impl Into<String>) -> Self {
153 Self {
154 description: description.into(),
155 priority: None,
156 }
157 }
158
159 pub fn with_priority(description: impl Into<String>, priority: DriverPriority) -> Self {
161 Self {
162 description: description.into(),
163 priority: Some(priority),
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct DecisionOption {
171 pub name: String,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub description: Option<String>,
176 #[serde(default, skip_serializing_if = "Vec::is_empty")]
178 pub pros: Vec<String>,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub cons: Vec<String>,
182 pub selected: bool,
184}
185
186impl DecisionOption {
187 pub fn new(name: impl Into<String>, selected: bool) -> Self {
189 Self {
190 name: name.into(),
191 description: None,
192 pros: Vec::new(),
193 cons: Vec::new(),
194 selected,
195 }
196 }
197
198 pub fn with_details(
200 name: impl Into<String>,
201 description: impl Into<String>,
202 pros: Vec<String>,
203 cons: Vec<String>,
204 selected: bool,
205 ) -> Self {
206 Self {
207 name: name.into(),
208 description: Some(description.into()),
209 pros,
210 cons,
211 selected,
212 }
213 }
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(rename_all = "lowercase")]
219pub enum AssetRelationship {
220 Affects,
222 Implements,
224 Deprecates,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230#[serde(rename_all = "camelCase")]
231pub struct AssetLink {
232 pub asset_type: String,
234 pub asset_id: Uuid,
236 pub asset_name: String,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub relationship: Option<AssetRelationship>,
241}
242
243impl AssetLink {
244 pub fn new(
246 asset_type: impl Into<String>,
247 asset_id: Uuid,
248 asset_name: impl Into<String>,
249 ) -> Self {
250 Self {
251 asset_type: asset_type.into(),
252 asset_id,
253 asset_name: asset_name.into(),
254 relationship: None,
255 }
256 }
257
258 pub fn with_relationship(
260 asset_type: impl Into<String>,
261 asset_id: Uuid,
262 asset_name: impl Into<String>,
263 relationship: AssetRelationship,
264 ) -> Self {
265 Self {
266 asset_type: asset_type.into(),
267 asset_id,
268 asset_name: asset_name.into(),
269 relationship: Some(relationship),
270 }
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
276#[serde(rename_all = "camelCase")]
277pub struct ComplianceAssessment {
278 #[serde(skip_serializing_if = "Option::is_none")]
280 pub regulatory_impact: Option<String>,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 pub privacy_assessment: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub security_assessment: Option<String>,
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub frameworks: Vec<String>,
290}
291
292impl ComplianceAssessment {
293 pub fn is_empty(&self) -> bool {
295 self.regulatory_impact.is_none()
296 && self.privacy_assessment.is_none()
297 && self.security_assessment.is_none()
298 && self.frameworks.is_empty()
299 }
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
304pub struct DecisionContact {
305 #[serde(skip_serializing_if = "Option::is_none")]
307 pub email: Option<String>,
308 #[serde(skip_serializing_if = "Option::is_none")]
310 pub name: Option<String>,
311 #[serde(skip_serializing_if = "Option::is_none")]
313 pub role: Option<String>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
318pub struct RaciMatrix {
319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub responsible: Vec<String>,
322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
324 pub accountable: Vec<String>,
325 #[serde(default, skip_serializing_if = "Vec::is_empty")]
327 pub consulted: Vec<String>,
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub informed: Vec<String>,
331}
332
333impl RaciMatrix {
334 pub fn is_empty(&self) -> bool {
336 self.responsible.is_empty()
337 && self.accountable.is_empty()
338 && self.consulted.is_empty()
339 && self.informed.is_empty()
340 }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
347#[serde(rename_all = "camelCase")]
348pub struct Decision {
349 pub id: Uuid,
351 pub number: u64,
354 pub title: String,
356 pub status: DecisionStatus,
358 pub category: DecisionCategory,
360 #[serde(skip_serializing_if = "Option::is_none")]
362 pub domain: Option<String>,
363 #[serde(skip_serializing_if = "Option::is_none")]
365 pub domain_id: Option<Uuid>,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 pub workspace_id: Option<Uuid>,
369
370 pub date: DateTime<Utc>,
373 #[serde(skip_serializing_if = "Option::is_none")]
375 pub decided_at: Option<DateTime<Utc>>,
376 #[serde(default, skip_serializing_if = "Vec::is_empty")]
378 pub authors: Vec<String>,
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
381 pub deciders: Vec<String>,
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
384 pub consulted: Vec<String>,
385 #[serde(default, skip_serializing_if = "Vec::is_empty")]
387 pub informed: Vec<String>,
388 pub context: String,
390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
392 pub drivers: Vec<DecisionDriver>,
393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
395 pub options: Vec<DecisionOption>,
396 pub decision: String,
398 #[serde(skip_serializing_if = "Option::is_none")]
400 pub consequences: Option<String>,
401
402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
405 pub linked_assets: Vec<AssetLink>,
406 #[serde(skip_serializing_if = "Option::is_none")]
408 pub supersedes: Option<Uuid>,
409 #[serde(skip_serializing_if = "Option::is_none")]
411 pub superseded_by: Option<Uuid>,
412 #[serde(default, skip_serializing_if = "Vec::is_empty")]
414 pub related_decisions: Vec<Uuid>,
415 #[serde(default, skip_serializing_if = "Vec::is_empty")]
417 pub related_knowledge: Vec<Uuid>,
418
419 #[serde(skip_serializing_if = "Option::is_none")]
422 pub compliance: Option<ComplianceAssessment>,
423
424 #[serde(skip_serializing_if = "Option::is_none")]
427 pub confirmation_date: Option<DateTime<Utc>>,
428 #[serde(skip_serializing_if = "Option::is_none")]
430 pub confirmation_notes: Option<String>,
431
432 #[serde(default, skip_serializing_if = "Vec::is_empty")]
435 pub tags: Vec<Tag>,
436 #[serde(skip_serializing_if = "Option::is_none")]
438 pub notes: Option<String>,
439
440 pub created_at: DateTime<Utc>,
442 pub updated_at: DateTime<Utc>,
444}
445
446impl Decision {
447 pub fn new(
449 number: u64,
450 title: impl Into<String>,
451 context: impl Into<String>,
452 decision: impl Into<String>,
453 ) -> Self {
454 let now = Utc::now();
455 Self {
456 id: Self::generate_id(number),
457 number,
458 title: title.into(),
459 status: DecisionStatus::Proposed,
460 category: DecisionCategory::Architecture,
461 domain: None,
462 domain_id: None,
463 workspace_id: None,
464 date: now,
465 decided_at: None,
466 authors: Vec::new(),
467 deciders: Vec::new(),
468 consulted: Vec::new(),
469 informed: Vec::new(),
470 context: context.into(),
471 drivers: Vec::new(),
472 options: Vec::new(),
473 decision: decision.into(),
474 consequences: None,
475 linked_assets: Vec::new(),
476 supersedes: None,
477 superseded_by: None,
478 related_decisions: Vec::new(),
479 related_knowledge: Vec::new(),
480 compliance: None,
481 confirmation_date: None,
482 confirmation_notes: None,
483 tags: Vec::new(),
484 notes: None,
485 created_at: now,
486 updated_at: now,
487 }
488 }
489
490 pub fn new_with_timestamp(
493 title: impl Into<String>,
494 context: impl Into<String>,
495 decision: impl Into<String>,
496 ) -> Self {
497 let now = Utc::now();
498 let number = Self::generate_timestamp_number(&now);
499 Self::new(number, title, context, decision)
500 }
501
502 pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
504 let formatted = dt.format("%y%m%d%H%M").to_string();
505 formatted.parse().unwrap_or(0)
506 }
507
508 pub fn generate_id(number: u64) -> Uuid {
510 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("decision:{}", number);
513 Uuid::new_v5(&namespace, name.as_bytes())
514 }
515
516 pub fn add_author(mut self, author: impl Into<String>) -> Self {
518 self.authors.push(author.into());
519 self.updated_at = Utc::now();
520 self
521 }
522
523 pub fn add_consulted(mut self, consulted: impl Into<String>) -> Self {
525 self.consulted.push(consulted.into());
526 self.updated_at = Utc::now();
527 self
528 }
529
530 pub fn add_informed(mut self, informed: impl Into<String>) -> Self {
532 self.informed.push(informed.into());
533 self.updated_at = Utc::now();
534 self
535 }
536
537 pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
539 self.related_decisions.push(decision_id);
540 self.updated_at = Utc::now();
541 self
542 }
543
544 pub fn add_related_knowledge(mut self, article_id: Uuid) -> Self {
546 self.related_knowledge.push(article_id);
547 self.updated_at = Utc::now();
548 self
549 }
550
551 pub fn with_decided_at(mut self, decided_at: DateTime<Utc>) -> Self {
553 self.decided_at = Some(decided_at);
554 self.updated_at = Utc::now();
555 self
556 }
557
558 pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
560 self.domain_id = Some(domain_id);
561 self.updated_at = Utc::now();
562 self
563 }
564
565 pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
567 self.workspace_id = Some(workspace_id);
568 self.updated_at = Utc::now();
569 self
570 }
571
572 pub fn with_status(mut self, status: DecisionStatus) -> Self {
574 self.status = status;
575 self.updated_at = Utc::now();
576 self
577 }
578
579 pub fn with_category(mut self, category: DecisionCategory) -> Self {
581 self.category = category;
582 self.updated_at = Utc::now();
583 self
584 }
585
586 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
588 self.domain = Some(domain.into());
589 self.updated_at = Utc::now();
590 self
591 }
592
593 pub fn add_decider(mut self, decider: impl Into<String>) -> Self {
595 self.deciders.push(decider.into());
596 self.updated_at = Utc::now();
597 self
598 }
599
600 pub fn add_driver(mut self, driver: DecisionDriver) -> Self {
602 self.drivers.push(driver);
603 self.updated_at = Utc::now();
604 self
605 }
606
607 pub fn add_option(mut self, option: DecisionOption) -> Self {
609 self.options.push(option);
610 self.updated_at = Utc::now();
611 self
612 }
613
614 pub fn with_consequences(mut self, consequences: impl Into<String>) -> Self {
616 self.consequences = Some(consequences.into());
617 self.updated_at = Utc::now();
618 self
619 }
620
621 pub fn add_asset_link(mut self, link: AssetLink) -> Self {
623 self.linked_assets.push(link);
624 self.updated_at = Utc::now();
625 self
626 }
627
628 pub fn with_compliance(mut self, compliance: ComplianceAssessment) -> Self {
630 self.compliance = Some(compliance);
631 self.updated_at = Utc::now();
632 self
633 }
634
635 pub fn supersedes_decision(mut self, other_id: Uuid) -> Self {
637 self.supersedes = Some(other_id);
638 self.updated_at = Utc::now();
639 self
640 }
641
642 pub fn superseded_by_decision(&mut self, other_id: Uuid) {
644 self.superseded_by = Some(other_id);
645 self.status = DecisionStatus::Superseded;
646 self.updated_at = Utc::now();
647 }
648
649 pub fn add_tag(mut self, tag: Tag) -> Self {
651 self.tags.push(tag);
652 self.updated_at = Utc::now();
653 self
654 }
655
656 pub fn is_timestamp_number(&self) -> bool {
658 self.number >= 1000000000 && self.number <= 9999999999
659 }
660
661 pub fn formatted_number(&self) -> String {
664 if self.is_timestamp_number() {
665 format!("ADR-{}", self.number)
666 } else {
667 format!("ADR-{:04}", self.number)
668 }
669 }
670
671 pub fn filename(&self, workspace_name: &str) -> String {
673 let number_str = if self.is_timestamp_number() {
674 format!("{}", self.number)
675 } else {
676 format!("{:04}", self.number)
677 };
678
679 match &self.domain {
680 Some(domain) => format!(
681 "{}_{}_adr-{}.madr.yaml",
682 sanitize_name(workspace_name),
683 sanitize_name(domain),
684 number_str
685 ),
686 None => format!(
687 "{}_adr-{}.madr.yaml",
688 sanitize_name(workspace_name),
689 number_str
690 ),
691 }
692 }
693
694 pub fn markdown_filename(&self) -> String {
696 let slug = slugify(&self.title);
697 if self.is_timestamp_number() {
698 format!("ADR-{}-{}.md", self.number, slug)
699 } else {
700 format!("ADR-{:04}-{}.md", self.number, slug)
701 }
702 }
703
704 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
706 serde_yaml::from_str(yaml_content)
707 }
708
709 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
711 serde_yaml::to_string(self)
712 }
713
714 pub fn to_yaml_pretty(&self) -> Result<String, serde_yaml::Error> {
716 serde_yaml::to_string(self)
718 }
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
723pub struct DecisionIndexEntry {
724 pub number: u64,
726 pub id: Uuid,
728 pub title: String,
730 pub status: DecisionStatus,
732 pub category: DecisionCategory,
734 #[serde(skip_serializing_if = "Option::is_none")]
736 pub domain: Option<String>,
737 pub file: String,
739}
740
741impl From<&Decision> for DecisionIndexEntry {
742 fn from(decision: &Decision) -> Self {
743 Self {
744 number: decision.number,
745 id: decision.id,
746 title: decision.title.clone(),
747 status: decision.status.clone(),
748 category: decision.category.clone(),
749 domain: decision.domain.clone(),
750 file: String::new(), }
752 }
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
757pub struct DecisionIndex {
758 #[serde(alias = "schema_version")]
760 pub schema_version: String,
761 #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
763 pub last_updated: Option<DateTime<Utc>>,
764 #[serde(default)]
766 pub decisions: Vec<DecisionIndexEntry>,
767 #[serde(alias = "next_number")]
769 pub next_number: u64,
770 #[serde(default, alias = "use_timestamp_numbering")]
772 pub use_timestamp_numbering: bool,
773}
774
775impl Default for DecisionIndex {
776 fn default() -> Self {
777 Self::new()
778 }
779}
780
781impl DecisionIndex {
782 pub fn new() -> Self {
784 Self {
785 schema_version: "1.0".to_string(),
786 last_updated: Some(Utc::now()),
787 decisions: Vec::new(),
788 next_number: 1,
789 use_timestamp_numbering: false,
790 }
791 }
792
793 pub fn new_with_timestamp_numbering() -> Self {
795 Self {
796 schema_version: "1.0".to_string(),
797 last_updated: Some(Utc::now()),
798 decisions: Vec::new(),
799 next_number: 1,
800 use_timestamp_numbering: true,
801 }
802 }
803
804 pub fn add_decision(&mut self, decision: &Decision, filename: String) {
806 let mut entry = DecisionIndexEntry::from(decision);
807 entry.file = filename;
808
809 self.decisions.retain(|d| d.number != decision.number);
811 self.decisions.push(entry);
812
813 self.decisions.sort_by_key(|d| d.number);
815
816 if !self.use_timestamp_numbering && decision.number >= self.next_number {
818 self.next_number = decision.number + 1;
819 }
820
821 self.last_updated = Some(Utc::now());
822 }
823
824 pub fn get_next_number(&self) -> u64 {
828 if self.use_timestamp_numbering {
829 Decision::generate_timestamp_number(&Utc::now())
830 } else {
831 self.next_number
832 }
833 }
834
835 pub fn find_by_number(&self, number: u64) -> Option<&DecisionIndexEntry> {
837 self.decisions.iter().find(|d| d.number == number)
838 }
839
840 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
842 serde_yaml::from_str(yaml_content)
843 }
844
845 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
847 serde_yaml::to_string(self)
848 }
849}
850
851fn sanitize_name(name: &str) -> String {
853 name.chars()
854 .map(|c| match c {
855 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
856 _ => c,
857 })
858 .collect::<String>()
859 .to_lowercase()
860}
861
862fn slugify(title: &str) -> String {
864 title
865 .to_lowercase()
866 .chars()
867 .map(|c| if c.is_alphanumeric() { c } else { '-' })
868 .collect::<String>()
869 .split('-')
870 .filter(|s| !s.is_empty())
871 .collect::<Vec<_>>()
872 .join("-")
873 .chars()
874 .take(50) .collect()
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881
882 #[test]
883 fn test_decision_new() {
884 let decision = Decision::new(
885 1,
886 "Use ODCS v3.1.0",
887 "We need a standard format",
888 "We will use ODCS v3.1.0",
889 );
890
891 assert_eq!(decision.number, 1);
892 assert_eq!(decision.title, "Use ODCS v3.1.0");
893 assert_eq!(decision.status, DecisionStatus::Proposed);
894 assert_eq!(decision.category, DecisionCategory::Architecture);
895 }
896
897 #[test]
898 fn test_decision_builder_pattern() {
899 let decision = Decision::new(1, "Test", "Context", "Decision")
900 .with_status(DecisionStatus::Accepted)
901 .with_category(DecisionCategory::DataDesign)
902 .with_domain("sales")
903 .add_decider("team@example.com")
904 .add_driver(DecisionDriver::with_priority(
905 "Need consistency",
906 DriverPriority::High,
907 ))
908 .with_consequences("Better consistency");
909
910 assert_eq!(decision.status, DecisionStatus::Accepted);
911 assert_eq!(decision.category, DecisionCategory::DataDesign);
912 assert_eq!(decision.domain, Some("sales".to_string()));
913 assert_eq!(decision.deciders.len(), 1);
914 assert_eq!(decision.drivers.len(), 1);
915 assert!(decision.consequences.is_some());
916 }
917
918 #[test]
919 fn test_decision_id_generation() {
920 let id1 = Decision::generate_id(1);
921 let id2 = Decision::generate_id(1);
922 let id3 = Decision::generate_id(2);
923
924 assert_eq!(id1, id2);
926 assert_ne!(id1, id3);
928 }
929
930 #[test]
931 fn test_decision_filename() {
932 let decision = Decision::new(1, "Test", "Context", "Decision");
933 assert_eq!(
934 decision.filename("enterprise"),
935 "enterprise_adr-0001.madr.yaml"
936 );
937
938 let decision_with_domain = decision.with_domain("sales");
939 assert_eq!(
940 decision_with_domain.filename("enterprise"),
941 "enterprise_sales_adr-0001.madr.yaml"
942 );
943 }
944
945 #[test]
946 fn test_decision_markdown_filename() {
947 let decision = Decision::new(
948 1,
949 "Use ODCS v3.1.0 for all data contracts",
950 "Context",
951 "Decision",
952 );
953 let filename = decision.markdown_filename();
954 assert!(filename.starts_with("ADR-0001-"));
955 assert!(filename.ends_with(".md"));
956 }
957
958 #[test]
959 fn test_decision_yaml_roundtrip() {
960 let decision = Decision::new(1, "Test Decision", "Some context", "The decision")
961 .with_status(DecisionStatus::Accepted)
962 .with_domain("test");
963
964 let yaml = decision.to_yaml().unwrap();
965 let parsed = Decision::from_yaml(&yaml).unwrap();
966
967 assert_eq!(decision.id, parsed.id);
968 assert_eq!(decision.title, parsed.title);
969 assert_eq!(decision.status, parsed.status);
970 assert_eq!(decision.domain, parsed.domain);
971 }
972
973 #[test]
974 fn test_decision_index() {
975 let mut index = DecisionIndex::new();
976 assert_eq!(index.get_next_number(), 1);
977
978 let decision1 = Decision::new(1, "First", "Context", "Decision");
979 index.add_decision(&decision1, "test_adr-0001.madr.yaml".to_string());
980
981 assert_eq!(index.decisions.len(), 1);
982 assert_eq!(index.get_next_number(), 2);
983
984 let decision2 = Decision::new(2, "Second", "Context", "Decision");
985 index.add_decision(&decision2, "test_adr-0002.madr.yaml".to_string());
986
987 assert_eq!(index.decisions.len(), 2);
988 assert_eq!(index.get_next_number(), 3);
989 }
990
991 #[test]
992 fn test_slugify() {
993 assert_eq!(slugify("Use ODCS v3.1.0"), "use-odcs-v3-1-0");
994 assert_eq!(slugify("Hello World"), "hello-world");
995 assert_eq!(slugify("test--double"), "test-double");
996 }
997
998 #[test]
999 fn test_decision_status_display() {
1000 assert_eq!(format!("{}", DecisionStatus::Proposed), "Proposed");
1001 assert_eq!(format!("{}", DecisionStatus::Accepted), "Accepted");
1002 assert_eq!(format!("{}", DecisionStatus::Deprecated), "Deprecated");
1003 assert_eq!(format!("{}", DecisionStatus::Superseded), "Superseded");
1004 assert_eq!(format!("{}", DecisionStatus::Rejected), "Rejected");
1005 }
1006
1007 #[test]
1008 fn test_asset_link() {
1009 let link = AssetLink::with_relationship(
1010 "odcs",
1011 Uuid::new_v4(),
1012 "orders",
1013 AssetRelationship::Implements,
1014 );
1015
1016 assert_eq!(link.asset_type, "odcs");
1017 assert_eq!(link.asset_name, "orders");
1018 assert_eq!(link.relationship, Some(AssetRelationship::Implements));
1019 }
1020
1021 #[test]
1022 fn test_timestamp_number_generation() {
1023 use chrono::TimeZone;
1024 let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
1025 let number = Decision::generate_timestamp_number(&dt);
1026 assert_eq!(number, 2601101430);
1027 }
1028
1029 #[test]
1030 fn test_is_timestamp_number() {
1031 let sequential_decision = Decision::new(1, "Test", "Context", "Decision");
1032 assert!(!sequential_decision.is_timestamp_number());
1033
1034 let timestamp_decision = Decision::new(2601101430, "Test", "Context", "Decision");
1035 assert!(timestamp_decision.is_timestamp_number());
1036 }
1037
1038 #[test]
1039 fn test_timestamp_decision_filename() {
1040 let decision = Decision::new(2601101430, "Test", "Context", "Decision");
1041 assert_eq!(
1042 decision.filename("enterprise"),
1043 "enterprise_adr-2601101430.madr.yaml"
1044 );
1045 }
1046
1047 #[test]
1048 fn test_timestamp_decision_markdown_filename() {
1049 let decision = Decision::new(2601101430, "Test Decision", "Context", "Decision");
1050 let filename = decision.markdown_filename();
1051 assert!(filename.starts_with("ADR-2601101430-"));
1052 assert!(filename.ends_with(".md"));
1053 }
1054
1055 #[test]
1056 fn test_decision_with_consulted_informed() {
1057 let decision = Decision::new(1, "Test", "Context", "Decision")
1058 .add_consulted("security@example.com")
1059 .add_informed("stakeholders@example.com");
1060
1061 assert_eq!(decision.consulted.len(), 1);
1062 assert_eq!(decision.informed.len(), 1);
1063 assert_eq!(decision.consulted[0], "security@example.com");
1064 assert_eq!(decision.informed[0], "stakeholders@example.com");
1065 }
1066
1067 #[test]
1068 fn test_decision_with_authors() {
1069 let decision = Decision::new(1, "Test", "Context", "Decision")
1070 .add_author("author1@example.com")
1071 .add_author("author2@example.com");
1072
1073 assert_eq!(decision.authors.len(), 2);
1074 }
1075
1076 #[test]
1077 fn test_decision_index_with_timestamp_numbering() {
1078 let index = DecisionIndex::new_with_timestamp_numbering();
1079 assert!(index.use_timestamp_numbering);
1080
1081 let next = index.get_next_number();
1083 assert!(next >= 1000000000); }
1085
1086 #[test]
1087 fn test_new_categories() {
1088 assert_eq!(format!("{}", DecisionCategory::Data), "Data");
1089 assert_eq!(format!("{}", DecisionCategory::Integration), "Integration");
1090 }
1091
1092 #[test]
1093 fn test_decision_with_related() {
1094 let related_decision_id = Uuid::new_v4();
1095 let related_knowledge_id = Uuid::new_v4();
1096
1097 let decision = Decision::new(1, "Test", "Context", "Decision")
1098 .add_related_decision(related_decision_id)
1099 .add_related_knowledge(related_knowledge_id);
1100
1101 assert_eq!(decision.related_decisions.len(), 1);
1102 assert_eq!(decision.related_knowledge.len(), 1);
1103 assert_eq!(decision.related_decisions[0], related_decision_id);
1104 assert_eq!(decision.related_knowledge[0], related_knowledge_id);
1105 }
1106
1107 #[test]
1108 fn test_decision_status_draft() {
1109 let decision =
1110 Decision::new(1, "Test", "Context", "Decision").with_status(DecisionStatus::Draft);
1111 assert_eq!(decision.status, DecisionStatus::Draft);
1112 assert_eq!(format!("{}", DecisionStatus::Draft), "Draft");
1113 }
1114}