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 #[serde(alias = "asset_type")]
234 pub asset_type: String,
235 #[serde(alias = "asset_id")]
237 pub asset_id: Uuid,
238 #[serde(alias = "asset_name")]
240 pub asset_name: String,
241 #[serde(skip_serializing_if = "Option::is_none")]
243 pub relationship: Option<AssetRelationship>,
244}
245
246impl AssetLink {
247 pub fn new(
249 asset_type: impl Into<String>,
250 asset_id: Uuid,
251 asset_name: impl Into<String>,
252 ) -> Self {
253 Self {
254 asset_type: asset_type.into(),
255 asset_id,
256 asset_name: asset_name.into(),
257 relationship: None,
258 }
259 }
260
261 pub fn with_relationship(
263 asset_type: impl Into<String>,
264 asset_id: Uuid,
265 asset_name: impl Into<String>,
266 relationship: AssetRelationship,
267 ) -> Self {
268 Self {
269 asset_type: asset_type.into(),
270 asset_id,
271 asset_name: asset_name.into(),
272 relationship: Some(relationship),
273 }
274 }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
279#[serde(rename_all = "camelCase")]
280pub struct ComplianceAssessment {
281 #[serde(skip_serializing_if = "Option::is_none", alias = "regulatory_impact")]
283 pub regulatory_impact: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none", alias = "privacy_assessment")]
286 pub privacy_assessment: Option<String>,
287 #[serde(skip_serializing_if = "Option::is_none", alias = "security_assessment")]
289 pub security_assessment: Option<String>,
290 #[serde(default, skip_serializing_if = "Vec::is_empty")]
292 pub frameworks: Vec<String>,
293}
294
295impl ComplianceAssessment {
296 pub fn is_empty(&self) -> bool {
298 self.regulatory_impact.is_none()
299 && self.privacy_assessment.is_none()
300 && self.security_assessment.is_none()
301 && self.frameworks.is_empty()
302 }
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
307pub struct DecisionContact {
308 #[serde(skip_serializing_if = "Option::is_none")]
310 pub email: Option<String>,
311 #[serde(skip_serializing_if = "Option::is_none")]
313 pub name: Option<String>,
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub role: Option<String>,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
321pub struct RaciMatrix {
322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
324 pub responsible: Vec<String>,
325 #[serde(default, skip_serializing_if = "Vec::is_empty")]
327 pub accountable: Vec<String>,
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub consulted: Vec<String>,
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
333 pub informed: Vec<String>,
334}
335
336impl RaciMatrix {
337 pub fn is_empty(&self) -> bool {
339 self.responsible.is_empty()
340 && self.accountable.is_empty()
341 && self.consulted.is_empty()
342 && self.informed.is_empty()
343 }
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
350#[serde(rename_all = "camelCase")]
351pub struct Decision {
352 pub id: Uuid,
354 pub number: u64,
357 pub title: String,
359 pub status: DecisionStatus,
361 pub category: DecisionCategory,
363 #[serde(skip_serializing_if = "Option::is_none")]
365 pub domain: Option<String>,
366 #[serde(skip_serializing_if = "Option::is_none", alias = "domain_id")]
368 pub domain_id: Option<Uuid>,
369 #[serde(skip_serializing_if = "Option::is_none", alias = "workspace_id")]
371 pub workspace_id: Option<Uuid>,
372
373 pub date: DateTime<Utc>,
376 #[serde(skip_serializing_if = "Option::is_none", alias = "decided_at")]
378 pub decided_at: Option<DateTime<Utc>>,
379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
381 pub authors: Vec<String>,
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
384 pub deciders: Vec<String>,
385 #[serde(default, skip_serializing_if = "Vec::is_empty")]
387 pub consulted: Vec<String>,
388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
390 pub informed: Vec<String>,
391 pub context: String,
393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
395 pub drivers: Vec<DecisionDriver>,
396 #[serde(default, skip_serializing_if = "Vec::is_empty")]
398 pub options: Vec<DecisionOption>,
399 pub decision: String,
401 #[serde(skip_serializing_if = "Option::is_none")]
403 pub consequences: Option<String>,
404
405 #[serde(
408 default,
409 skip_serializing_if = "Vec::is_empty",
410 alias = "linked_assets"
411 )]
412 pub linked_assets: Vec<AssetLink>,
413 #[serde(skip_serializing_if = "Option::is_none")]
415 pub supersedes: Option<Uuid>,
416 #[serde(skip_serializing_if = "Option::is_none", alias = "superseded_by")]
418 pub superseded_by: Option<Uuid>,
419 #[serde(
421 default,
422 skip_serializing_if = "Vec::is_empty",
423 alias = "related_decisions"
424 )]
425 pub related_decisions: Vec<Uuid>,
426 #[serde(
428 default,
429 skip_serializing_if = "Vec::is_empty",
430 alias = "related_knowledge"
431 )]
432 pub related_knowledge: Vec<Uuid>,
433 #[serde(
435 default,
436 skip_serializing_if = "Vec::is_empty",
437 alias = "linked_sketches"
438 )]
439 pub linked_sketches: Vec<Uuid>,
440
441 #[serde(skip_serializing_if = "Option::is_none")]
444 pub compliance: Option<ComplianceAssessment>,
445
446 #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_date")]
449 pub confirmation_date: Option<DateTime<Utc>>,
450 #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_notes")]
452 pub confirmation_notes: Option<String>,
453
454 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 pub tags: Vec<Tag>,
458 #[serde(skip_serializing_if = "Option::is_none")]
460 pub notes: Option<String>,
461
462 #[serde(alias = "created_at")]
464 pub created_at: DateTime<Utc>,
465 #[serde(alias = "updated_at")]
467 pub updated_at: DateTime<Utc>,
468}
469
470impl Decision {
471 pub fn new(
473 number: u64,
474 title: impl Into<String>,
475 context: impl Into<String>,
476 decision: impl Into<String>,
477 ) -> Self {
478 let now = Utc::now();
479 Self {
480 id: Self::generate_id(number),
481 number,
482 title: title.into(),
483 status: DecisionStatus::Proposed,
484 category: DecisionCategory::Architecture,
485 domain: None,
486 domain_id: None,
487 workspace_id: None,
488 date: now,
489 decided_at: None,
490 authors: Vec::new(),
491 deciders: Vec::new(),
492 consulted: Vec::new(),
493 informed: Vec::new(),
494 context: context.into(),
495 drivers: Vec::new(),
496 options: Vec::new(),
497 decision: decision.into(),
498 consequences: None,
499 linked_assets: Vec::new(),
500 supersedes: None,
501 superseded_by: None,
502 related_decisions: Vec::new(),
503 related_knowledge: Vec::new(),
504 linked_sketches: Vec::new(),
505 compliance: None,
506 confirmation_date: None,
507 confirmation_notes: None,
508 tags: Vec::new(),
509 notes: None,
510 created_at: now,
511 updated_at: now,
512 }
513 }
514
515 pub fn new_with_timestamp(
518 title: impl Into<String>,
519 context: impl Into<String>,
520 decision: impl Into<String>,
521 ) -> Self {
522 let now = Utc::now();
523 let number = Self::generate_timestamp_number(&now);
524 Self::new(number, title, context, decision)
525 }
526
527 pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
529 let formatted = dt.format("%y%m%d%H%M").to_string();
530 formatted.parse().unwrap_or(0)
531 }
532
533 pub fn generate_id(number: u64) -> Uuid {
535 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("decision:{}", number);
538 Uuid::new_v5(&namespace, name.as_bytes())
539 }
540
541 pub fn add_author(mut self, author: impl Into<String>) -> Self {
543 self.authors.push(author.into());
544 self.updated_at = Utc::now();
545 self
546 }
547
548 pub fn add_consulted(mut self, consulted: impl Into<String>) -> Self {
550 self.consulted.push(consulted.into());
551 self.updated_at = Utc::now();
552 self
553 }
554
555 pub fn add_informed(mut self, informed: impl Into<String>) -> Self {
557 self.informed.push(informed.into());
558 self.updated_at = Utc::now();
559 self
560 }
561
562 pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
564 self.related_decisions.push(decision_id);
565 self.updated_at = Utc::now();
566 self
567 }
568
569 pub fn add_related_knowledge(mut self, article_id: Uuid) -> Self {
571 self.related_knowledge.push(article_id);
572 self.updated_at = Utc::now();
573 self
574 }
575
576 pub fn link_sketch(mut self, sketch_id: Uuid) -> Self {
578 if !self.linked_sketches.contains(&sketch_id) {
579 self.linked_sketches.push(sketch_id);
580 self.updated_at = Utc::now();
581 }
582 self
583 }
584
585 pub fn with_decided_at(mut self, decided_at: DateTime<Utc>) -> Self {
587 self.decided_at = Some(decided_at);
588 self.updated_at = Utc::now();
589 self
590 }
591
592 pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
594 self.domain_id = Some(domain_id);
595 self.updated_at = Utc::now();
596 self
597 }
598
599 pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
601 self.workspace_id = Some(workspace_id);
602 self.updated_at = Utc::now();
603 self
604 }
605
606 pub fn with_status(mut self, status: DecisionStatus) -> Self {
608 self.status = status;
609 self.updated_at = Utc::now();
610 self
611 }
612
613 pub fn with_category(mut self, category: DecisionCategory) -> Self {
615 self.category = category;
616 self.updated_at = Utc::now();
617 self
618 }
619
620 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
622 self.domain = Some(domain.into());
623 self.updated_at = Utc::now();
624 self
625 }
626
627 pub fn add_decider(mut self, decider: impl Into<String>) -> Self {
629 self.deciders.push(decider.into());
630 self.updated_at = Utc::now();
631 self
632 }
633
634 pub fn add_driver(mut self, driver: DecisionDriver) -> Self {
636 self.drivers.push(driver);
637 self.updated_at = Utc::now();
638 self
639 }
640
641 pub fn add_option(mut self, option: DecisionOption) -> Self {
643 self.options.push(option);
644 self.updated_at = Utc::now();
645 self
646 }
647
648 pub fn with_consequences(mut self, consequences: impl Into<String>) -> Self {
650 self.consequences = Some(consequences.into());
651 self.updated_at = Utc::now();
652 self
653 }
654
655 pub fn add_asset_link(mut self, link: AssetLink) -> Self {
657 self.linked_assets.push(link);
658 self.updated_at = Utc::now();
659 self
660 }
661
662 pub fn with_compliance(mut self, compliance: ComplianceAssessment) -> Self {
664 self.compliance = Some(compliance);
665 self.updated_at = Utc::now();
666 self
667 }
668
669 pub fn supersedes_decision(mut self, other_id: Uuid) -> Self {
671 self.supersedes = Some(other_id);
672 self.updated_at = Utc::now();
673 self
674 }
675
676 pub fn superseded_by_decision(&mut self, other_id: Uuid) {
678 self.superseded_by = Some(other_id);
679 self.status = DecisionStatus::Superseded;
680 self.updated_at = Utc::now();
681 }
682
683 pub fn add_tag(mut self, tag: Tag) -> Self {
685 self.tags.push(tag);
686 self.updated_at = Utc::now();
687 self
688 }
689
690 pub fn is_timestamp_number(&self) -> bool {
692 self.number >= 1000000000 && self.number <= 9999999999
693 }
694
695 pub fn formatted_number(&self) -> String {
698 if self.is_timestamp_number() {
699 format!("ADR-{}", self.number)
700 } else {
701 format!("ADR-{:04}", self.number)
702 }
703 }
704
705 pub fn filename(&self, workspace_name: &str) -> String {
707 let number_str = if self.is_timestamp_number() {
708 format!("{}", self.number)
709 } else {
710 format!("{:04}", self.number)
711 };
712
713 match &self.domain {
714 Some(domain) => format!(
715 "{}_{}_adr-{}.madr.yaml",
716 sanitize_name(workspace_name),
717 sanitize_name(domain),
718 number_str
719 ),
720 None => format!(
721 "{}_adr-{}.madr.yaml",
722 sanitize_name(workspace_name),
723 number_str
724 ),
725 }
726 }
727
728 pub fn markdown_filename(&self) -> String {
730 let slug = slugify(&self.title);
731 if self.is_timestamp_number() {
732 format!("ADR-{}-{}.md", self.number, slug)
733 } else {
734 format!("ADR-{:04}-{}.md", self.number, slug)
735 }
736 }
737
738 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
740 serde_yaml::from_str(yaml_content)
741 }
742
743 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
745 serde_yaml::to_string(self)
746 }
747
748 pub fn to_yaml_pretty(&self) -> Result<String, serde_yaml::Error> {
750 serde_yaml::to_string(self)
752 }
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
757pub struct DecisionIndexEntry {
758 pub number: u64,
760 pub id: Uuid,
762 pub title: String,
764 pub status: DecisionStatus,
766 pub category: DecisionCategory,
768 #[serde(skip_serializing_if = "Option::is_none")]
770 pub domain: Option<String>,
771 pub file: String,
773}
774
775impl From<&Decision> for DecisionIndexEntry {
776 fn from(decision: &Decision) -> Self {
777 Self {
778 number: decision.number,
779 id: decision.id,
780 title: decision.title.clone(),
781 status: decision.status.clone(),
782 category: decision.category.clone(),
783 domain: decision.domain.clone(),
784 file: String::new(), }
786 }
787}
788
789#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
791pub struct DecisionIndex {
792 #[serde(alias = "schema_version")]
794 pub schema_version: String,
795 #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
797 pub last_updated: Option<DateTime<Utc>>,
798 #[serde(default)]
800 pub decisions: Vec<DecisionIndexEntry>,
801 #[serde(alias = "next_number")]
803 pub next_number: u64,
804 #[serde(default, alias = "use_timestamp_numbering")]
806 pub use_timestamp_numbering: bool,
807}
808
809impl Default for DecisionIndex {
810 fn default() -> Self {
811 Self::new()
812 }
813}
814
815impl DecisionIndex {
816 pub fn new() -> Self {
818 Self {
819 schema_version: "1.0".to_string(),
820 last_updated: Some(Utc::now()),
821 decisions: Vec::new(),
822 next_number: 1,
823 use_timestamp_numbering: false,
824 }
825 }
826
827 pub fn new_with_timestamp_numbering() -> Self {
829 Self {
830 schema_version: "1.0".to_string(),
831 last_updated: Some(Utc::now()),
832 decisions: Vec::new(),
833 next_number: 1,
834 use_timestamp_numbering: true,
835 }
836 }
837
838 pub fn add_decision(&mut self, decision: &Decision, filename: String) {
840 let mut entry = DecisionIndexEntry::from(decision);
841 entry.file = filename;
842
843 self.decisions.retain(|d| d.number != decision.number);
845 self.decisions.push(entry);
846
847 self.decisions.sort_by_key(|d| d.number);
849
850 if !self.use_timestamp_numbering && decision.number >= self.next_number {
852 self.next_number = decision.number + 1;
853 }
854
855 self.last_updated = Some(Utc::now());
856 }
857
858 pub fn get_next_number(&self) -> u64 {
862 if self.use_timestamp_numbering {
863 Decision::generate_timestamp_number(&Utc::now())
864 } else {
865 self.next_number
866 }
867 }
868
869 pub fn find_by_number(&self, number: u64) -> Option<&DecisionIndexEntry> {
871 self.decisions.iter().find(|d| d.number == number)
872 }
873
874 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
876 serde_yaml::from_str(yaml_content)
877 }
878
879 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
881 serde_yaml::to_string(self)
882 }
883}
884
885fn sanitize_name(name: &str) -> String {
887 name.chars()
888 .map(|c| match c {
889 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
890 _ => c,
891 })
892 .collect::<String>()
893 .to_lowercase()
894}
895
896fn slugify(title: &str) -> String {
898 title
899 .to_lowercase()
900 .chars()
901 .map(|c| if c.is_alphanumeric() { c } else { '-' })
902 .collect::<String>()
903 .split('-')
904 .filter(|s| !s.is_empty())
905 .collect::<Vec<_>>()
906 .join("-")
907 .chars()
908 .take(50) .collect()
910}
911
912#[cfg(test)]
913mod tests {
914 use super::*;
915
916 #[test]
917 fn test_decision_new() {
918 let decision = Decision::new(
919 1,
920 "Use ODCS v3.1.0",
921 "We need a standard format",
922 "We will use ODCS v3.1.0",
923 );
924
925 assert_eq!(decision.number, 1);
926 assert_eq!(decision.title, "Use ODCS v3.1.0");
927 assert_eq!(decision.status, DecisionStatus::Proposed);
928 assert_eq!(decision.category, DecisionCategory::Architecture);
929 }
930
931 #[test]
932 fn test_decision_builder_pattern() {
933 let decision = Decision::new(1, "Test", "Context", "Decision")
934 .with_status(DecisionStatus::Accepted)
935 .with_category(DecisionCategory::DataDesign)
936 .with_domain("sales")
937 .add_decider("team@example.com")
938 .add_driver(DecisionDriver::with_priority(
939 "Need consistency",
940 DriverPriority::High,
941 ))
942 .with_consequences("Better consistency");
943
944 assert_eq!(decision.status, DecisionStatus::Accepted);
945 assert_eq!(decision.category, DecisionCategory::DataDesign);
946 assert_eq!(decision.domain, Some("sales".to_string()));
947 assert_eq!(decision.deciders.len(), 1);
948 assert_eq!(decision.drivers.len(), 1);
949 assert!(decision.consequences.is_some());
950 }
951
952 #[test]
953 fn test_decision_id_generation() {
954 let id1 = Decision::generate_id(1);
955 let id2 = Decision::generate_id(1);
956 let id3 = Decision::generate_id(2);
957
958 assert_eq!(id1, id2);
960 assert_ne!(id1, id3);
962 }
963
964 #[test]
965 fn test_decision_filename() {
966 let decision = Decision::new(1, "Test", "Context", "Decision");
967 assert_eq!(
968 decision.filename("enterprise"),
969 "enterprise_adr-0001.madr.yaml"
970 );
971
972 let decision_with_domain = decision.with_domain("sales");
973 assert_eq!(
974 decision_with_domain.filename("enterprise"),
975 "enterprise_sales_adr-0001.madr.yaml"
976 );
977 }
978
979 #[test]
980 fn test_decision_markdown_filename() {
981 let decision = Decision::new(
982 1,
983 "Use ODCS v3.1.0 for all data contracts",
984 "Context",
985 "Decision",
986 );
987 let filename = decision.markdown_filename();
988 assert!(filename.starts_with("ADR-0001-"));
989 assert!(filename.ends_with(".md"));
990 }
991
992 #[test]
993 fn test_decision_yaml_roundtrip() {
994 let decision = Decision::new(1, "Test Decision", "Some context", "The decision")
995 .with_status(DecisionStatus::Accepted)
996 .with_domain("test");
997
998 let yaml = decision.to_yaml().unwrap();
999 let parsed = Decision::from_yaml(&yaml).unwrap();
1000
1001 assert_eq!(decision.id, parsed.id);
1002 assert_eq!(decision.title, parsed.title);
1003 assert_eq!(decision.status, parsed.status);
1004 assert_eq!(decision.domain, parsed.domain);
1005 }
1006
1007 #[test]
1008 fn test_decision_index() {
1009 let mut index = DecisionIndex::new();
1010 assert_eq!(index.get_next_number(), 1);
1011
1012 let decision1 = Decision::new(1, "First", "Context", "Decision");
1013 index.add_decision(&decision1, "test_adr-0001.madr.yaml".to_string());
1014
1015 assert_eq!(index.decisions.len(), 1);
1016 assert_eq!(index.get_next_number(), 2);
1017
1018 let decision2 = Decision::new(2, "Second", "Context", "Decision");
1019 index.add_decision(&decision2, "test_adr-0002.madr.yaml".to_string());
1020
1021 assert_eq!(index.decisions.len(), 2);
1022 assert_eq!(index.get_next_number(), 3);
1023 }
1024
1025 #[test]
1026 fn test_slugify() {
1027 assert_eq!(slugify("Use ODCS v3.1.0"), "use-odcs-v3-1-0");
1028 assert_eq!(slugify("Hello World"), "hello-world");
1029 assert_eq!(slugify("test--double"), "test-double");
1030 }
1031
1032 #[test]
1033 fn test_decision_status_display() {
1034 assert_eq!(format!("{}", DecisionStatus::Proposed), "Proposed");
1035 assert_eq!(format!("{}", DecisionStatus::Accepted), "Accepted");
1036 assert_eq!(format!("{}", DecisionStatus::Deprecated), "Deprecated");
1037 assert_eq!(format!("{}", DecisionStatus::Superseded), "Superseded");
1038 assert_eq!(format!("{}", DecisionStatus::Rejected), "Rejected");
1039 }
1040
1041 #[test]
1042 fn test_asset_link() {
1043 let link = AssetLink::with_relationship(
1044 "odcs",
1045 Uuid::new_v4(),
1046 "orders",
1047 AssetRelationship::Implements,
1048 );
1049
1050 assert_eq!(link.asset_type, "odcs");
1051 assert_eq!(link.asset_name, "orders");
1052 assert_eq!(link.relationship, Some(AssetRelationship::Implements));
1053 }
1054
1055 #[test]
1056 fn test_timestamp_number_generation() {
1057 use chrono::TimeZone;
1058 let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
1059 let number = Decision::generate_timestamp_number(&dt);
1060 assert_eq!(number, 2601101430);
1061 }
1062
1063 #[test]
1064 fn test_is_timestamp_number() {
1065 let sequential_decision = Decision::new(1, "Test", "Context", "Decision");
1066 assert!(!sequential_decision.is_timestamp_number());
1067
1068 let timestamp_decision = Decision::new(2601101430, "Test", "Context", "Decision");
1069 assert!(timestamp_decision.is_timestamp_number());
1070 }
1071
1072 #[test]
1073 fn test_timestamp_decision_filename() {
1074 let decision = Decision::new(2601101430, "Test", "Context", "Decision");
1075 assert_eq!(
1076 decision.filename("enterprise"),
1077 "enterprise_adr-2601101430.madr.yaml"
1078 );
1079 }
1080
1081 #[test]
1082 fn test_timestamp_decision_markdown_filename() {
1083 let decision = Decision::new(2601101430, "Test Decision", "Context", "Decision");
1084 let filename = decision.markdown_filename();
1085 assert!(filename.starts_with("ADR-2601101430-"));
1086 assert!(filename.ends_with(".md"));
1087 }
1088
1089 #[test]
1090 fn test_decision_with_consulted_informed() {
1091 let decision = Decision::new(1, "Test", "Context", "Decision")
1092 .add_consulted("security@example.com")
1093 .add_informed("stakeholders@example.com");
1094
1095 assert_eq!(decision.consulted.len(), 1);
1096 assert_eq!(decision.informed.len(), 1);
1097 assert_eq!(decision.consulted[0], "security@example.com");
1098 assert_eq!(decision.informed[0], "stakeholders@example.com");
1099 }
1100
1101 #[test]
1102 fn test_decision_with_authors() {
1103 let decision = Decision::new(1, "Test", "Context", "Decision")
1104 .add_author("author1@example.com")
1105 .add_author("author2@example.com");
1106
1107 assert_eq!(decision.authors.len(), 2);
1108 }
1109
1110 #[test]
1111 fn test_decision_index_with_timestamp_numbering() {
1112 let index = DecisionIndex::new_with_timestamp_numbering();
1113 assert!(index.use_timestamp_numbering);
1114
1115 let next = index.get_next_number();
1117 assert!(next >= 1000000000); }
1119
1120 #[test]
1121 fn test_new_categories() {
1122 assert_eq!(format!("{}", DecisionCategory::Data), "Data");
1123 assert_eq!(format!("{}", DecisionCategory::Integration), "Integration");
1124 }
1125
1126 #[test]
1127 fn test_decision_with_related() {
1128 let related_decision_id = Uuid::new_v4();
1129 let related_knowledge_id = Uuid::new_v4();
1130
1131 let decision = Decision::new(1, "Test", "Context", "Decision")
1132 .add_related_decision(related_decision_id)
1133 .add_related_knowledge(related_knowledge_id);
1134
1135 assert_eq!(decision.related_decisions.len(), 1);
1136 assert_eq!(decision.related_knowledge.len(), 1);
1137 assert_eq!(decision.related_decisions[0], related_decision_id);
1138 assert_eq!(decision.related_knowledge[0], related_knowledge_id);
1139 }
1140
1141 #[test]
1142 fn test_decision_status_draft() {
1143 let decision =
1144 Decision::new(1, "Test", "Context", "Decision").with_status(DecisionStatus::Draft);
1145 assert_eq!(decision.status, DecisionStatus::Draft);
1146 assert_eq!(format!("{}", DecisionStatus::Draft), "Draft");
1147 }
1148}