1use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use uuid::Uuid;
36
37use super::Tag;
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "lowercase")]
44pub enum DecisionStatus {
45 Draft,
47 #[default]
49 Proposed,
50 Accepted,
52 Rejected,
54 Superseded,
56 Deprecated,
58}
59
60impl std::fmt::Display for DecisionStatus {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 DecisionStatus::Draft => write!(f, "Draft"),
64 DecisionStatus::Proposed => write!(f, "Proposed"),
65 DecisionStatus::Accepted => write!(f, "Accepted"),
66 DecisionStatus::Rejected => write!(f, "Rejected"),
67 DecisionStatus::Superseded => write!(f, "Superseded"),
68 DecisionStatus::Deprecated => write!(f, "Deprecated"),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
77#[serde(rename_all = "lowercase")]
78pub enum DecisionCategory {
79 #[default]
81 Architecture,
82 Technology,
84 Process,
86 Security,
88 Data,
90 Integration,
92 DataDesign,
94 Workflow,
96 Model,
98 Governance,
100 Performance,
102 Compliance,
104 Infrastructure,
106 Tooling,
108}
109
110impl std::fmt::Display for DecisionCategory {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match self {
113 DecisionCategory::Architecture => write!(f, "Architecture"),
114 DecisionCategory::Technology => write!(f, "Technology"),
115 DecisionCategory::Process => write!(f, "Process"),
116 DecisionCategory::Security => write!(f, "Security"),
117 DecisionCategory::Data => write!(f, "Data"),
118 DecisionCategory::Integration => write!(f, "Integration"),
119 DecisionCategory::DataDesign => write!(f, "Data Design"),
120 DecisionCategory::Workflow => write!(f, "Workflow"),
121 DecisionCategory::Model => write!(f, "Model"),
122 DecisionCategory::Governance => write!(f, "Governance"),
123 DecisionCategory::Performance => write!(f, "Performance"),
124 DecisionCategory::Compliance => write!(f, "Compliance"),
125 DecisionCategory::Infrastructure => write!(f, "Infrastructure"),
126 DecisionCategory::Tooling => write!(f, "Tooling"),
127 }
128 }
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
133#[serde(rename_all = "lowercase")]
134pub enum DriverPriority {
135 High,
136 #[default]
137 Medium,
138 Low,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
143pub struct DecisionDriver {
144 pub description: String,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub priority: Option<DriverPriority>,
149}
150
151impl DecisionDriver {
152 pub fn new(description: impl Into<String>) -> Self {
154 Self {
155 description: description.into(),
156 priority: None,
157 }
158 }
159
160 pub fn with_priority(description: impl Into<String>, priority: DriverPriority) -> Self {
162 Self {
163 description: description.into(),
164 priority: Some(priority),
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct DecisionOption {
172 pub name: String,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub description: Option<String>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub pros: Vec<String>,
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub cons: Vec<String>,
183 pub selected: bool,
185}
186
187impl DecisionOption {
188 pub fn new(name: impl Into<String>, selected: bool) -> Self {
190 Self {
191 name: name.into(),
192 description: None,
193 pros: Vec::new(),
194 cons: Vec::new(),
195 selected,
196 }
197 }
198
199 pub fn with_details(
201 name: impl Into<String>,
202 description: impl Into<String>,
203 pros: Vec<String>,
204 cons: Vec<String>,
205 selected: bool,
206 ) -> Self {
207 Self {
208 name: name.into(),
209 description: Some(description.into()),
210 pros,
211 cons,
212 selected,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
219#[serde(rename_all = "lowercase")]
220pub enum AssetRelationship {
221 Affects,
223 Implements,
225 Deprecates,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
231#[serde(rename_all = "camelCase")]
232pub struct AssetLink {
233 #[serde(alias = "asset_type")]
235 pub asset_type: String,
236 #[serde(alias = "asset_id")]
238 pub asset_id: Uuid,
239 #[serde(alias = "asset_name")]
241 pub asset_name: String,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub relationship: Option<AssetRelationship>,
245}
246
247impl AssetLink {
248 pub fn new(
250 asset_type: impl Into<String>,
251 asset_id: Uuid,
252 asset_name: impl Into<String>,
253 ) -> Self {
254 Self {
255 asset_type: asset_type.into(),
256 asset_id,
257 asset_name: asset_name.into(),
258 relationship: None,
259 }
260 }
261
262 pub fn with_relationship(
264 asset_type: impl Into<String>,
265 asset_id: Uuid,
266 asset_name: impl Into<String>,
267 relationship: AssetRelationship,
268 ) -> Self {
269 Self {
270 asset_type: asset_type.into(),
271 asset_id,
272 asset_name: asset_name.into(),
273 relationship: Some(relationship),
274 }
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
280#[serde(rename_all = "camelCase")]
281pub struct ComplianceAssessment {
282 #[serde(skip_serializing_if = "Option::is_none", alias = "regulatory_impact")]
284 pub regulatory_impact: Option<String>,
285 #[serde(skip_serializing_if = "Option::is_none", alias = "privacy_assessment")]
287 pub privacy_assessment: Option<String>,
288 #[serde(skip_serializing_if = "Option::is_none", alias = "security_assessment")]
290 pub security_assessment: Option<String>,
291 #[serde(default, skip_serializing_if = "Vec::is_empty")]
293 pub frameworks: Vec<String>,
294}
295
296impl ComplianceAssessment {
297 pub fn is_empty(&self) -> bool {
299 self.regulatory_impact.is_none()
300 && self.privacy_assessment.is_none()
301 && self.security_assessment.is_none()
302 && self.frameworks.is_empty()
303 }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
308pub struct DecisionContact {
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub email: Option<String>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub name: Option<String>,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 pub role: Option<String>,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
322pub struct RaciMatrix {
323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
325 pub responsible: Vec<String>,
326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub accountable: Vec<String>,
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
331 pub consulted: Vec<String>,
332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub informed: Vec<String>,
335}
336
337impl RaciMatrix {
338 pub fn is_empty(&self) -> bool {
340 self.responsible.is_empty()
341 && self.accountable.is_empty()
342 && self.consulted.is_empty()
343 && self.informed.is_empty()
344 }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
351#[serde(rename_all = "camelCase")]
352pub struct Decision {
353 pub id: Uuid,
355 pub number: u64,
358 pub title: String,
360 pub status: DecisionStatus,
362 pub category: DecisionCategory,
364 #[serde(skip_serializing_if = "Option::is_none")]
366 pub domain: Option<String>,
367 #[serde(skip_serializing_if = "Option::is_none", alias = "domain_id")]
369 pub domain_id: Option<Uuid>,
370 #[serde(skip_serializing_if = "Option::is_none", alias = "workspace_id")]
372 pub workspace_id: Option<Uuid>,
373
374 pub date: DateTime<Utc>,
377 #[serde(skip_serializing_if = "Option::is_none", alias = "decided_at")]
379 pub decided_at: Option<DateTime<Utc>>,
380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
382 pub authors: Vec<String>,
383 #[serde(default, skip_serializing_if = "Vec::is_empty")]
385 pub deciders: Vec<String>,
386 #[serde(default, skip_serializing_if = "Vec::is_empty")]
388 pub consulted: Vec<String>,
389 #[serde(default, skip_serializing_if = "Vec::is_empty")]
391 pub informed: Vec<String>,
392 pub context: String,
394 #[serde(default, skip_serializing_if = "Vec::is_empty")]
396 pub drivers: Vec<DecisionDriver>,
397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub options: Vec<DecisionOption>,
400 pub decision: String,
402 #[serde(skip_serializing_if = "Option::is_none")]
404 pub consequences: Option<String>,
405
406 #[serde(
409 default,
410 skip_serializing_if = "Vec::is_empty",
411 alias = "linked_assets"
412 )]
413 pub linked_assets: Vec<AssetLink>,
414 #[serde(skip_serializing_if = "Option::is_none")]
416 pub supersedes: Option<Uuid>,
417 #[serde(skip_serializing_if = "Option::is_none", alias = "superseded_by")]
419 pub superseded_by: Option<Uuid>,
420 #[serde(
422 default,
423 skip_serializing_if = "Vec::is_empty",
424 alias = "related_decisions"
425 )]
426 pub related_decisions: Vec<Uuid>,
427 #[serde(
429 default,
430 skip_serializing_if = "Vec::is_empty",
431 alias = "related_knowledge"
432 )]
433 pub related_knowledge: Vec<Uuid>,
434 #[serde(
436 default,
437 skip_serializing_if = "Vec::is_empty",
438 alias = "linked_sketches"
439 )]
440 pub linked_sketches: Vec<Uuid>,
441
442 #[serde(skip_serializing_if = "Option::is_none")]
445 pub compliance: Option<ComplianceAssessment>,
446
447 #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_date")]
450 pub confirmation_date: Option<DateTime<Utc>>,
451 #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_notes")]
453 pub confirmation_notes: Option<String>,
454
455 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub tags: Vec<Tag>,
459 #[serde(skip_serializing_if = "Option::is_none")]
461 pub notes: Option<String>,
462 #[serde(
464 default,
465 skip_serializing_if = "HashMap::is_empty",
466 alias = "custom_properties"
467 )]
468 pub custom_properties: HashMap<String, serde_json::Value>,
469
470 #[serde(alias = "created_at")]
472 pub created_at: DateTime<Utc>,
473 #[serde(alias = "updated_at")]
475 pub updated_at: DateTime<Utc>,
476}
477
478impl Decision {
479 pub fn new(
483 number: u64,
484 title: impl Into<String>,
485 context: impl Into<String>,
486 decision: impl Into<String>,
487 author: impl Into<String>,
488 ) -> Self {
489 let now = Utc::now();
490 Self {
491 id: Self::generate_id(number),
492 number,
493 title: title.into(),
494 status: DecisionStatus::Proposed,
495 category: DecisionCategory::Architecture,
496 domain: None,
497 domain_id: None,
498 workspace_id: None,
499 date: now,
500 decided_at: None,
501 authors: vec![author.into()],
502 deciders: Vec::new(),
503 consulted: Vec::new(),
504 informed: Vec::new(),
505 context: context.into(),
506 drivers: Vec::new(),
507 options: Vec::new(),
508 decision: decision.into(),
509 consequences: None,
510 linked_assets: Vec::new(),
511 supersedes: None,
512 superseded_by: None,
513 related_decisions: Vec::new(),
514 related_knowledge: Vec::new(),
515 linked_sketches: Vec::new(),
516 compliance: None,
517 confirmation_date: None,
518 confirmation_notes: None,
519 tags: Vec::new(),
520 notes: None,
521 custom_properties: HashMap::new(),
522 created_at: now,
523 updated_at: now,
524 }
525 }
526
527 pub fn new_with_timestamp(
532 title: impl Into<String>,
533 context: impl Into<String>,
534 decision: impl Into<String>,
535 author: impl Into<String>,
536 ) -> Self {
537 let now = Utc::now();
538 let number = Self::generate_timestamp_number(&now);
539 Self::new(number, title, context, decision, author)
540 }
541
542 pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
544 let formatted = dt.format("%y%m%d%H%M").to_string();
545 formatted.parse().unwrap_or(0)
546 }
547
548 pub fn generate_id(number: u64) -> Uuid {
550 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("decision:{}", number);
553 Uuid::new_v5(&namespace, name.as_bytes())
554 }
555
556 pub fn add_author(mut self, author: impl Into<String>) -> Self {
558 self.authors.push(author.into());
559 self.updated_at = Utc::now();
560 self
561 }
562
563 pub fn add_consulted(mut self, consulted: impl Into<String>) -> Self {
565 self.consulted.push(consulted.into());
566 self.updated_at = Utc::now();
567 self
568 }
569
570 pub fn add_informed(mut self, informed: impl Into<String>) -> Self {
572 self.informed.push(informed.into());
573 self.updated_at = Utc::now();
574 self
575 }
576
577 pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
579 self.related_decisions.push(decision_id);
580 self.updated_at = Utc::now();
581 self
582 }
583
584 pub fn add_related_knowledge(mut self, article_id: Uuid) -> Self {
586 self.related_knowledge.push(article_id);
587 self.updated_at = Utc::now();
588 self
589 }
590
591 pub fn link_sketch(mut self, sketch_id: Uuid) -> Self {
593 if !self.linked_sketches.contains(&sketch_id) {
594 self.linked_sketches.push(sketch_id);
595 self.updated_at = Utc::now();
596 }
597 self
598 }
599
600 pub fn with_decided_at(mut self, decided_at: DateTime<Utc>) -> Self {
602 self.decided_at = Some(decided_at);
603 self.updated_at = Utc::now();
604 self
605 }
606
607 pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
609 self.domain_id = Some(domain_id);
610 self.updated_at = Utc::now();
611 self
612 }
613
614 pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
616 self.workspace_id = Some(workspace_id);
617 self.updated_at = Utc::now();
618 self
619 }
620
621 pub fn with_status(mut self, status: DecisionStatus) -> Self {
623 self.status = status;
624 self.updated_at = Utc::now();
625 self
626 }
627
628 pub fn with_category(mut self, category: DecisionCategory) -> Self {
630 self.category = category;
631 self.updated_at = Utc::now();
632 self
633 }
634
635 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
637 self.domain = Some(domain.into());
638 self.updated_at = Utc::now();
639 self
640 }
641
642 pub fn add_decider(mut self, decider: impl Into<String>) -> Self {
644 self.deciders.push(decider.into());
645 self.updated_at = Utc::now();
646 self
647 }
648
649 pub fn add_driver(mut self, driver: DecisionDriver) -> Self {
651 self.drivers.push(driver);
652 self.updated_at = Utc::now();
653 self
654 }
655
656 pub fn add_option(mut self, option: DecisionOption) -> Self {
658 self.options.push(option);
659 self.updated_at = Utc::now();
660 self
661 }
662
663 pub fn with_consequences(mut self, consequences: impl Into<String>) -> Self {
665 self.consequences = Some(consequences.into());
666 self.updated_at = Utc::now();
667 self
668 }
669
670 pub fn add_asset_link(mut self, link: AssetLink) -> Self {
672 self.linked_assets.push(link);
673 self.updated_at = Utc::now();
674 self
675 }
676
677 pub fn with_compliance(mut self, compliance: ComplianceAssessment) -> Self {
679 self.compliance = Some(compliance);
680 self.updated_at = Utc::now();
681 self
682 }
683
684 pub fn supersedes_decision(mut self, other_id: Uuid) -> Self {
686 self.supersedes = Some(other_id);
687 self.updated_at = Utc::now();
688 self
689 }
690
691 pub fn superseded_by_decision(&mut self, other_id: Uuid) {
693 self.superseded_by = Some(other_id);
694 self.status = DecisionStatus::Superseded;
695 self.updated_at = Utc::now();
696 }
697
698 pub fn add_tag(mut self, tag: Tag) -> Self {
700 self.tags.push(tag);
701 self.updated_at = Utc::now();
702 self
703 }
704
705 pub fn is_timestamp_number(&self) -> bool {
707 self.number >= 1000000000 && self.number <= 9999999999
708 }
709
710 pub fn formatted_number(&self) -> String {
713 if self.is_timestamp_number() {
714 format!("ADR-{}", self.number)
715 } else {
716 format!("ADR-{:04}", self.number)
717 }
718 }
719
720 pub fn filename(&self, workspace_name: &str) -> String {
722 let number_str = if self.is_timestamp_number() {
723 format!("{}", self.number)
724 } else {
725 format!("{:04}", self.number)
726 };
727
728 match &self.domain {
729 Some(domain) => format!(
730 "{}_{}_adr-{}.madr.yaml",
731 sanitize_name(workspace_name),
732 sanitize_name(domain),
733 number_str
734 ),
735 None => format!(
736 "{}_adr-{}.madr.yaml",
737 sanitize_name(workspace_name),
738 number_str
739 ),
740 }
741 }
742
743 pub fn markdown_filename(&self) -> String {
745 let slug = slugify(&self.title);
746 if self.is_timestamp_number() {
747 format!("ADR-{}-{}.md", self.number, slug)
748 } else {
749 format!("ADR-{:04}-{}.md", self.number, slug)
750 }
751 }
752
753 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
755 serde_yaml::from_str(yaml_content)
756 }
757
758 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
760 serde_yaml::to_string(self)
761 }
762
763 pub fn to_yaml_pretty(&self) -> Result<String, serde_yaml::Error> {
765 serde_yaml::to_string(self)
767 }
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
772pub struct DecisionIndexEntry {
773 pub number: u64,
775 pub id: Uuid,
777 pub title: String,
779 pub status: DecisionStatus,
781 pub category: DecisionCategory,
783 #[serde(skip_serializing_if = "Option::is_none")]
785 pub domain: Option<String>,
786 pub file: String,
788}
789
790impl From<&Decision> for DecisionIndexEntry {
791 fn from(decision: &Decision) -> Self {
792 Self {
793 number: decision.number,
794 id: decision.id,
795 title: decision.title.clone(),
796 status: decision.status.clone(),
797 category: decision.category.clone(),
798 domain: decision.domain.clone(),
799 file: String::new(), }
801 }
802}
803
804#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
806pub struct DecisionIndex {
807 #[serde(alias = "schema_version")]
809 pub schema_version: String,
810 #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
812 pub last_updated: Option<DateTime<Utc>>,
813 #[serde(default)]
815 pub decisions: Vec<DecisionIndexEntry>,
816 #[serde(alias = "next_number")]
818 pub next_number: u64,
819 #[serde(default, alias = "use_timestamp_numbering")]
821 pub use_timestamp_numbering: bool,
822}
823
824impl Default for DecisionIndex {
825 fn default() -> Self {
826 Self::new()
827 }
828}
829
830impl DecisionIndex {
831 pub fn new() -> Self {
833 Self {
834 schema_version: "1.0".to_string(),
835 last_updated: Some(Utc::now()),
836 decisions: Vec::new(),
837 next_number: 1,
838 use_timestamp_numbering: false,
839 }
840 }
841
842 pub fn new_with_timestamp_numbering() -> Self {
844 Self {
845 schema_version: "1.0".to_string(),
846 last_updated: Some(Utc::now()),
847 decisions: Vec::new(),
848 next_number: 1,
849 use_timestamp_numbering: true,
850 }
851 }
852
853 pub fn add_decision(&mut self, decision: &Decision, filename: String) {
855 let mut entry = DecisionIndexEntry::from(decision);
856 entry.file = filename;
857
858 self.decisions.retain(|d| d.number != decision.number);
860 self.decisions.push(entry);
861
862 self.decisions.sort_by_key(|d| d.number);
864
865 if !self.use_timestamp_numbering && decision.number >= self.next_number {
867 self.next_number = decision.number + 1;
868 }
869
870 self.last_updated = Some(Utc::now());
871 }
872
873 pub fn get_next_number(&self) -> u64 {
877 if self.use_timestamp_numbering {
878 Decision::generate_timestamp_number(&Utc::now())
879 } else {
880 self.next_number
881 }
882 }
883
884 pub fn find_by_number(&self, number: u64) -> Option<&DecisionIndexEntry> {
886 self.decisions.iter().find(|d| d.number == number)
887 }
888
889 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
891 serde_yaml::from_str(yaml_content)
892 }
893
894 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
896 serde_yaml::to_string(self)
897 }
898}
899
900fn sanitize_name(name: &str) -> String {
902 name.chars()
903 .map(|c| match c {
904 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
905 _ => c,
906 })
907 .collect::<String>()
908 .to_lowercase()
909}
910
911fn slugify(title: &str) -> String {
913 title
914 .to_lowercase()
915 .chars()
916 .map(|c| if c.is_alphanumeric() { c } else { '-' })
917 .collect::<String>()
918 .split('-')
919 .filter(|s| !s.is_empty())
920 .collect::<Vec<_>>()
921 .join("-")
922 .chars()
923 .take(50) .collect()
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930
931 #[test]
932 fn test_decision_new() {
933 let decision = Decision::new(
934 1,
935 "Use ODCS v3.1.0",
936 "We need a standard format",
937 "We will use ODCS v3.1.0",
938 "author@example.com",
939 );
940
941 assert_eq!(decision.number, 1);
942 assert_eq!(decision.title, "Use ODCS v3.1.0");
943 assert_eq!(decision.status, DecisionStatus::Proposed);
944 assert_eq!(decision.category, DecisionCategory::Architecture);
945 assert_eq!(decision.authors.len(), 1);
946 assert_eq!(decision.authors[0], "author@example.com");
947 }
948
949 #[test]
950 fn test_decision_builder_pattern() {
951 let decision = Decision::new(1, "Test", "Context", "Decision", "author@example.com")
952 .with_status(DecisionStatus::Accepted)
953 .with_category(DecisionCategory::DataDesign)
954 .with_domain("sales")
955 .add_decider("team@example.com")
956 .add_driver(DecisionDriver::with_priority(
957 "Need consistency",
958 DriverPriority::High,
959 ))
960 .with_consequences("Better consistency");
961
962 assert_eq!(decision.status, DecisionStatus::Accepted);
963 assert_eq!(decision.category, DecisionCategory::DataDesign);
964 assert_eq!(decision.domain, Some("sales".to_string()));
965 assert_eq!(decision.deciders.len(), 1);
966 assert_eq!(decision.drivers.len(), 1);
967 assert!(decision.consequences.is_some());
968 }
969
970 #[test]
971 fn test_decision_id_generation() {
972 let id1 = Decision::generate_id(1);
973 let id2 = Decision::generate_id(1);
974 let id3 = Decision::generate_id(2);
975
976 assert_eq!(id1, id2);
978 assert_ne!(id1, id3);
980 }
981
982 #[test]
983 fn test_decision_filename() {
984 let decision = Decision::new(1, "Test", "Context", "Decision", "author@example.com");
985 assert_eq!(
986 decision.filename("enterprise"),
987 "enterprise_adr-0001.madr.yaml"
988 );
989
990 let decision_with_domain = decision.with_domain("sales");
991 assert_eq!(
992 decision_with_domain.filename("enterprise"),
993 "enterprise_sales_adr-0001.madr.yaml"
994 );
995 }
996
997 #[test]
998 fn test_decision_markdown_filename() {
999 let decision = Decision::new(
1000 1,
1001 "Use ODCS v3.1.0 for all data contracts",
1002 "Context",
1003 "Decision",
1004 "author@example.com",
1005 );
1006 let filename = decision.markdown_filename();
1007 assert!(filename.starts_with("ADR-0001-"));
1008 assert!(filename.ends_with(".md"));
1009 }
1010
1011 #[test]
1012 fn test_decision_yaml_roundtrip() {
1013 let decision = Decision::new(
1014 1,
1015 "Test Decision",
1016 "Some context",
1017 "The decision",
1018 "author@example.com",
1019 )
1020 .with_status(DecisionStatus::Accepted)
1021 .with_domain("test");
1022
1023 let yaml = decision.to_yaml().unwrap();
1024 let parsed = Decision::from_yaml(&yaml).unwrap();
1025
1026 assert_eq!(decision.id, parsed.id);
1027 assert_eq!(decision.title, parsed.title);
1028 assert_eq!(decision.status, parsed.status);
1029 assert_eq!(decision.domain, parsed.domain);
1030 }
1031
1032 #[test]
1033 fn test_decision_index() {
1034 let mut index = DecisionIndex::new();
1035 assert_eq!(index.get_next_number(), 1);
1036
1037 let decision1 = Decision::new(1, "First", "Context", "Decision", "author@example.com");
1038 index.add_decision(&decision1, "test_adr-0001.madr.yaml".to_string());
1039
1040 assert_eq!(index.decisions.len(), 1);
1041 assert_eq!(index.get_next_number(), 2);
1042
1043 let decision2 = Decision::new(2, "Second", "Context", "Decision", "author@example.com");
1044 index.add_decision(&decision2, "test_adr-0002.madr.yaml".to_string());
1045
1046 assert_eq!(index.decisions.len(), 2);
1047 assert_eq!(index.get_next_number(), 3);
1048 }
1049
1050 #[test]
1051 fn test_slugify() {
1052 assert_eq!(slugify("Use ODCS v3.1.0"), "use-odcs-v3-1-0");
1053 assert_eq!(slugify("Hello World"), "hello-world");
1054 assert_eq!(slugify("test--double"), "test-double");
1055 }
1056
1057 #[test]
1058 fn test_decision_status_display() {
1059 assert_eq!(format!("{}", DecisionStatus::Proposed), "Proposed");
1060 assert_eq!(format!("{}", DecisionStatus::Accepted), "Accepted");
1061 assert_eq!(format!("{}", DecisionStatus::Deprecated), "Deprecated");
1062 assert_eq!(format!("{}", DecisionStatus::Superseded), "Superseded");
1063 assert_eq!(format!("{}", DecisionStatus::Rejected), "Rejected");
1064 }
1065
1066 #[test]
1067 fn test_asset_link() {
1068 let link = AssetLink::with_relationship(
1069 "odcs",
1070 Uuid::new_v4(),
1071 "orders",
1072 AssetRelationship::Implements,
1073 );
1074
1075 assert_eq!(link.asset_type, "odcs");
1076 assert_eq!(link.asset_name, "orders");
1077 assert_eq!(link.relationship, Some(AssetRelationship::Implements));
1078 }
1079
1080 #[test]
1081 fn test_timestamp_number_generation() {
1082 use chrono::TimeZone;
1083 let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
1084 let number = Decision::generate_timestamp_number(&dt);
1085 assert_eq!(number, 2601101430);
1086 }
1087
1088 #[test]
1089 fn test_is_timestamp_number() {
1090 let sequential_decision =
1091 Decision::new(1, "Test", "Context", "Decision", "author@example.com");
1092 assert!(!sequential_decision.is_timestamp_number());
1093
1094 let timestamp_decision = Decision::new(
1095 2601101430,
1096 "Test",
1097 "Context",
1098 "Decision",
1099 "author@example.com",
1100 );
1101 assert!(timestamp_decision.is_timestamp_number());
1102 }
1103
1104 #[test]
1105 fn test_timestamp_decision_filename() {
1106 let decision = Decision::new(
1107 2601101430,
1108 "Test",
1109 "Context",
1110 "Decision",
1111 "author@example.com",
1112 );
1113 assert_eq!(
1114 decision.filename("enterprise"),
1115 "enterprise_adr-2601101430.madr.yaml"
1116 );
1117 }
1118
1119 #[test]
1120 fn test_timestamp_decision_markdown_filename() {
1121 let decision = Decision::new(
1122 2601101430,
1123 "Test Decision",
1124 "Context",
1125 "Decision",
1126 "author@example.com",
1127 );
1128 let filename = decision.markdown_filename();
1129 assert!(filename.starts_with("ADR-2601101430-"));
1130 assert!(filename.ends_with(".md"));
1131 }
1132
1133 #[test]
1134 fn test_decision_with_consulted_informed() {
1135 let decision = Decision::new(1, "Test", "Context", "Decision", "author@example.com")
1136 .add_consulted("security@example.com")
1137 .add_informed("stakeholders@example.com");
1138
1139 assert_eq!(decision.consulted.len(), 1);
1140 assert_eq!(decision.informed.len(), 1);
1141 assert_eq!(decision.consulted[0], "security@example.com");
1142 assert_eq!(decision.informed[0], "stakeholders@example.com");
1143 }
1144
1145 #[test]
1146 fn test_decision_with_authors() {
1147 let decision = Decision::new(1, "Test", "Context", "Decision", "author1@example.com")
1148 .add_author("author2@example.com")
1149 .add_author("author3@example.com");
1150
1151 assert_eq!(decision.authors.len(), 3);
1152 }
1153
1154 #[test]
1155 fn test_decision_index_with_timestamp_numbering() {
1156 let index = DecisionIndex::new_with_timestamp_numbering();
1157 assert!(index.use_timestamp_numbering);
1158
1159 let next = index.get_next_number();
1161 assert!(next >= 1000000000); }
1163
1164 #[test]
1165 fn test_new_categories() {
1166 assert_eq!(format!("{}", DecisionCategory::Data), "Data");
1167 assert_eq!(format!("{}", DecisionCategory::Integration), "Integration");
1168 }
1169
1170 #[test]
1171 fn test_decision_with_related() {
1172 let related_decision_id = Uuid::new_v4();
1173 let related_knowledge_id = Uuid::new_v4();
1174
1175 let decision = Decision::new(1, "Test", "Context", "Decision", "author@example.com")
1176 .add_related_decision(related_decision_id)
1177 .add_related_knowledge(related_knowledge_id);
1178
1179 assert_eq!(decision.related_decisions.len(), 1);
1180 assert_eq!(decision.related_knowledge.len(), 1);
1181 assert_eq!(decision.related_decisions[0], related_decision_id);
1182 assert_eq!(decision.related_knowledge[0], related_knowledge_id);
1183 }
1184
1185 #[test]
1186 fn test_decision_status_draft() {
1187 let decision = Decision::new(1, "Test", "Context", "Decision", "author@example.com")
1188 .with_status(DecisionStatus::Draft);
1189 assert_eq!(decision.status, DecisionStatus::Draft);
1190 assert_eq!(format!("{}", DecisionStatus::Draft), "Draft");
1191 }
1192}