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
434 #[serde(skip_serializing_if = "Option::is_none")]
437 pub compliance: Option<ComplianceAssessment>,
438
439 #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_date")]
442 pub confirmation_date: Option<DateTime<Utc>>,
443 #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_notes")]
445 pub confirmation_notes: Option<String>,
446
447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
450 pub tags: Vec<Tag>,
451 #[serde(skip_serializing_if = "Option::is_none")]
453 pub notes: Option<String>,
454
455 #[serde(alias = "created_at")]
457 pub created_at: DateTime<Utc>,
458 #[serde(alias = "updated_at")]
460 pub updated_at: DateTime<Utc>,
461}
462
463impl Decision {
464 pub fn new(
466 number: u64,
467 title: impl Into<String>,
468 context: impl Into<String>,
469 decision: impl Into<String>,
470 ) -> Self {
471 let now = Utc::now();
472 Self {
473 id: Self::generate_id(number),
474 number,
475 title: title.into(),
476 status: DecisionStatus::Proposed,
477 category: DecisionCategory::Architecture,
478 domain: None,
479 domain_id: None,
480 workspace_id: None,
481 date: now,
482 decided_at: None,
483 authors: Vec::new(),
484 deciders: Vec::new(),
485 consulted: Vec::new(),
486 informed: Vec::new(),
487 context: context.into(),
488 drivers: Vec::new(),
489 options: Vec::new(),
490 decision: decision.into(),
491 consequences: None,
492 linked_assets: Vec::new(),
493 supersedes: None,
494 superseded_by: None,
495 related_decisions: Vec::new(),
496 related_knowledge: Vec::new(),
497 compliance: None,
498 confirmation_date: None,
499 confirmation_notes: None,
500 tags: Vec::new(),
501 notes: None,
502 created_at: now,
503 updated_at: now,
504 }
505 }
506
507 pub fn new_with_timestamp(
510 title: impl Into<String>,
511 context: impl Into<String>,
512 decision: impl Into<String>,
513 ) -> Self {
514 let now = Utc::now();
515 let number = Self::generate_timestamp_number(&now);
516 Self::new(number, title, context, decision)
517 }
518
519 pub fn generate_timestamp_number(dt: &DateTime<Utc>) -> u64 {
521 let formatted = dt.format("%y%m%d%H%M").to_string();
522 formatted.parse().unwrap_or(0)
523 }
524
525 pub fn generate_id(number: u64) -> Uuid {
527 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("decision:{}", number);
530 Uuid::new_v5(&namespace, name.as_bytes())
531 }
532
533 pub fn add_author(mut self, author: impl Into<String>) -> Self {
535 self.authors.push(author.into());
536 self.updated_at = Utc::now();
537 self
538 }
539
540 pub fn add_consulted(mut self, consulted: impl Into<String>) -> Self {
542 self.consulted.push(consulted.into());
543 self.updated_at = Utc::now();
544 self
545 }
546
547 pub fn add_informed(mut self, informed: impl Into<String>) -> Self {
549 self.informed.push(informed.into());
550 self.updated_at = Utc::now();
551 self
552 }
553
554 pub fn add_related_decision(mut self, decision_id: Uuid) -> Self {
556 self.related_decisions.push(decision_id);
557 self.updated_at = Utc::now();
558 self
559 }
560
561 pub fn add_related_knowledge(mut self, article_id: Uuid) -> Self {
563 self.related_knowledge.push(article_id);
564 self.updated_at = Utc::now();
565 self
566 }
567
568 pub fn with_decided_at(mut self, decided_at: DateTime<Utc>) -> Self {
570 self.decided_at = Some(decided_at);
571 self.updated_at = Utc::now();
572 self
573 }
574
575 pub fn with_domain_id(mut self, domain_id: Uuid) -> Self {
577 self.domain_id = Some(domain_id);
578 self.updated_at = Utc::now();
579 self
580 }
581
582 pub fn with_workspace_id(mut self, workspace_id: Uuid) -> Self {
584 self.workspace_id = Some(workspace_id);
585 self.updated_at = Utc::now();
586 self
587 }
588
589 pub fn with_status(mut self, status: DecisionStatus) -> Self {
591 self.status = status;
592 self.updated_at = Utc::now();
593 self
594 }
595
596 pub fn with_category(mut self, category: DecisionCategory) -> Self {
598 self.category = category;
599 self.updated_at = Utc::now();
600 self
601 }
602
603 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
605 self.domain = Some(domain.into());
606 self.updated_at = Utc::now();
607 self
608 }
609
610 pub fn add_decider(mut self, decider: impl Into<String>) -> Self {
612 self.deciders.push(decider.into());
613 self.updated_at = Utc::now();
614 self
615 }
616
617 pub fn add_driver(mut self, driver: DecisionDriver) -> Self {
619 self.drivers.push(driver);
620 self.updated_at = Utc::now();
621 self
622 }
623
624 pub fn add_option(mut self, option: DecisionOption) -> Self {
626 self.options.push(option);
627 self.updated_at = Utc::now();
628 self
629 }
630
631 pub fn with_consequences(mut self, consequences: impl Into<String>) -> Self {
633 self.consequences = Some(consequences.into());
634 self.updated_at = Utc::now();
635 self
636 }
637
638 pub fn add_asset_link(mut self, link: AssetLink) -> Self {
640 self.linked_assets.push(link);
641 self.updated_at = Utc::now();
642 self
643 }
644
645 pub fn with_compliance(mut self, compliance: ComplianceAssessment) -> Self {
647 self.compliance = Some(compliance);
648 self.updated_at = Utc::now();
649 self
650 }
651
652 pub fn supersedes_decision(mut self, other_id: Uuid) -> Self {
654 self.supersedes = Some(other_id);
655 self.updated_at = Utc::now();
656 self
657 }
658
659 pub fn superseded_by_decision(&mut self, other_id: Uuid) {
661 self.superseded_by = Some(other_id);
662 self.status = DecisionStatus::Superseded;
663 self.updated_at = Utc::now();
664 }
665
666 pub fn add_tag(mut self, tag: Tag) -> Self {
668 self.tags.push(tag);
669 self.updated_at = Utc::now();
670 self
671 }
672
673 pub fn is_timestamp_number(&self) -> bool {
675 self.number >= 1000000000 && self.number <= 9999999999
676 }
677
678 pub fn formatted_number(&self) -> String {
681 if self.is_timestamp_number() {
682 format!("ADR-{}", self.number)
683 } else {
684 format!("ADR-{:04}", self.number)
685 }
686 }
687
688 pub fn filename(&self, workspace_name: &str) -> String {
690 let number_str = if self.is_timestamp_number() {
691 format!("{}", self.number)
692 } else {
693 format!("{:04}", self.number)
694 };
695
696 match &self.domain {
697 Some(domain) => format!(
698 "{}_{}_adr-{}.madr.yaml",
699 sanitize_name(workspace_name),
700 sanitize_name(domain),
701 number_str
702 ),
703 None => format!(
704 "{}_adr-{}.madr.yaml",
705 sanitize_name(workspace_name),
706 number_str
707 ),
708 }
709 }
710
711 pub fn markdown_filename(&self) -> String {
713 let slug = slugify(&self.title);
714 if self.is_timestamp_number() {
715 format!("ADR-{}-{}.md", self.number, slug)
716 } else {
717 format!("ADR-{:04}-{}.md", self.number, slug)
718 }
719 }
720
721 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
723 serde_yaml::from_str(yaml_content)
724 }
725
726 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
728 serde_yaml::to_string(self)
729 }
730
731 pub fn to_yaml_pretty(&self) -> Result<String, serde_yaml::Error> {
733 serde_yaml::to_string(self)
735 }
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
740pub struct DecisionIndexEntry {
741 pub number: u64,
743 pub id: Uuid,
745 pub title: String,
747 pub status: DecisionStatus,
749 pub category: DecisionCategory,
751 #[serde(skip_serializing_if = "Option::is_none")]
753 pub domain: Option<String>,
754 pub file: String,
756}
757
758impl From<&Decision> for DecisionIndexEntry {
759 fn from(decision: &Decision) -> Self {
760 Self {
761 number: decision.number,
762 id: decision.id,
763 title: decision.title.clone(),
764 status: decision.status.clone(),
765 category: decision.category.clone(),
766 domain: decision.domain.clone(),
767 file: String::new(), }
769 }
770}
771
772#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
774pub struct DecisionIndex {
775 #[serde(alias = "schema_version")]
777 pub schema_version: String,
778 #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
780 pub last_updated: Option<DateTime<Utc>>,
781 #[serde(default)]
783 pub decisions: Vec<DecisionIndexEntry>,
784 #[serde(alias = "next_number")]
786 pub next_number: u64,
787 #[serde(default, alias = "use_timestamp_numbering")]
789 pub use_timestamp_numbering: bool,
790}
791
792impl Default for DecisionIndex {
793 fn default() -> Self {
794 Self::new()
795 }
796}
797
798impl DecisionIndex {
799 pub fn new() -> Self {
801 Self {
802 schema_version: "1.0".to_string(),
803 last_updated: Some(Utc::now()),
804 decisions: Vec::new(),
805 next_number: 1,
806 use_timestamp_numbering: false,
807 }
808 }
809
810 pub fn new_with_timestamp_numbering() -> Self {
812 Self {
813 schema_version: "1.0".to_string(),
814 last_updated: Some(Utc::now()),
815 decisions: Vec::new(),
816 next_number: 1,
817 use_timestamp_numbering: true,
818 }
819 }
820
821 pub fn add_decision(&mut self, decision: &Decision, filename: String) {
823 let mut entry = DecisionIndexEntry::from(decision);
824 entry.file = filename;
825
826 self.decisions.retain(|d| d.number != decision.number);
828 self.decisions.push(entry);
829
830 self.decisions.sort_by_key(|d| d.number);
832
833 if !self.use_timestamp_numbering && decision.number >= self.next_number {
835 self.next_number = decision.number + 1;
836 }
837
838 self.last_updated = Some(Utc::now());
839 }
840
841 pub fn get_next_number(&self) -> u64 {
845 if self.use_timestamp_numbering {
846 Decision::generate_timestamp_number(&Utc::now())
847 } else {
848 self.next_number
849 }
850 }
851
852 pub fn find_by_number(&self, number: u64) -> Option<&DecisionIndexEntry> {
854 self.decisions.iter().find(|d| d.number == number)
855 }
856
857 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
859 serde_yaml::from_str(yaml_content)
860 }
861
862 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
864 serde_yaml::to_string(self)
865 }
866}
867
868fn sanitize_name(name: &str) -> String {
870 name.chars()
871 .map(|c| match c {
872 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
873 _ => c,
874 })
875 .collect::<String>()
876 .to_lowercase()
877}
878
879fn slugify(title: &str) -> String {
881 title
882 .to_lowercase()
883 .chars()
884 .map(|c| if c.is_alphanumeric() { c } else { '-' })
885 .collect::<String>()
886 .split('-')
887 .filter(|s| !s.is_empty())
888 .collect::<Vec<_>>()
889 .join("-")
890 .chars()
891 .take(50) .collect()
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 #[test]
900 fn test_decision_new() {
901 let decision = Decision::new(
902 1,
903 "Use ODCS v3.1.0",
904 "We need a standard format",
905 "We will use ODCS v3.1.0",
906 );
907
908 assert_eq!(decision.number, 1);
909 assert_eq!(decision.title, "Use ODCS v3.1.0");
910 assert_eq!(decision.status, DecisionStatus::Proposed);
911 assert_eq!(decision.category, DecisionCategory::Architecture);
912 }
913
914 #[test]
915 fn test_decision_builder_pattern() {
916 let decision = Decision::new(1, "Test", "Context", "Decision")
917 .with_status(DecisionStatus::Accepted)
918 .with_category(DecisionCategory::DataDesign)
919 .with_domain("sales")
920 .add_decider("team@example.com")
921 .add_driver(DecisionDriver::with_priority(
922 "Need consistency",
923 DriverPriority::High,
924 ))
925 .with_consequences("Better consistency");
926
927 assert_eq!(decision.status, DecisionStatus::Accepted);
928 assert_eq!(decision.category, DecisionCategory::DataDesign);
929 assert_eq!(decision.domain, Some("sales".to_string()));
930 assert_eq!(decision.deciders.len(), 1);
931 assert_eq!(decision.drivers.len(), 1);
932 assert!(decision.consequences.is_some());
933 }
934
935 #[test]
936 fn test_decision_id_generation() {
937 let id1 = Decision::generate_id(1);
938 let id2 = Decision::generate_id(1);
939 let id3 = Decision::generate_id(2);
940
941 assert_eq!(id1, id2);
943 assert_ne!(id1, id3);
945 }
946
947 #[test]
948 fn test_decision_filename() {
949 let decision = Decision::new(1, "Test", "Context", "Decision");
950 assert_eq!(
951 decision.filename("enterprise"),
952 "enterprise_adr-0001.madr.yaml"
953 );
954
955 let decision_with_domain = decision.with_domain("sales");
956 assert_eq!(
957 decision_with_domain.filename("enterprise"),
958 "enterprise_sales_adr-0001.madr.yaml"
959 );
960 }
961
962 #[test]
963 fn test_decision_markdown_filename() {
964 let decision = Decision::new(
965 1,
966 "Use ODCS v3.1.0 for all data contracts",
967 "Context",
968 "Decision",
969 );
970 let filename = decision.markdown_filename();
971 assert!(filename.starts_with("ADR-0001-"));
972 assert!(filename.ends_with(".md"));
973 }
974
975 #[test]
976 fn test_decision_yaml_roundtrip() {
977 let decision = Decision::new(1, "Test Decision", "Some context", "The decision")
978 .with_status(DecisionStatus::Accepted)
979 .with_domain("test");
980
981 let yaml = decision.to_yaml().unwrap();
982 let parsed = Decision::from_yaml(&yaml).unwrap();
983
984 assert_eq!(decision.id, parsed.id);
985 assert_eq!(decision.title, parsed.title);
986 assert_eq!(decision.status, parsed.status);
987 assert_eq!(decision.domain, parsed.domain);
988 }
989
990 #[test]
991 fn test_decision_index() {
992 let mut index = DecisionIndex::new();
993 assert_eq!(index.get_next_number(), 1);
994
995 let decision1 = Decision::new(1, "First", "Context", "Decision");
996 index.add_decision(&decision1, "test_adr-0001.madr.yaml".to_string());
997
998 assert_eq!(index.decisions.len(), 1);
999 assert_eq!(index.get_next_number(), 2);
1000
1001 let decision2 = Decision::new(2, "Second", "Context", "Decision");
1002 index.add_decision(&decision2, "test_adr-0002.madr.yaml".to_string());
1003
1004 assert_eq!(index.decisions.len(), 2);
1005 assert_eq!(index.get_next_number(), 3);
1006 }
1007
1008 #[test]
1009 fn test_slugify() {
1010 assert_eq!(slugify("Use ODCS v3.1.0"), "use-odcs-v3-1-0");
1011 assert_eq!(slugify("Hello World"), "hello-world");
1012 assert_eq!(slugify("test--double"), "test-double");
1013 }
1014
1015 #[test]
1016 fn test_decision_status_display() {
1017 assert_eq!(format!("{}", DecisionStatus::Proposed), "Proposed");
1018 assert_eq!(format!("{}", DecisionStatus::Accepted), "Accepted");
1019 assert_eq!(format!("{}", DecisionStatus::Deprecated), "Deprecated");
1020 assert_eq!(format!("{}", DecisionStatus::Superseded), "Superseded");
1021 assert_eq!(format!("{}", DecisionStatus::Rejected), "Rejected");
1022 }
1023
1024 #[test]
1025 fn test_asset_link() {
1026 let link = AssetLink::with_relationship(
1027 "odcs",
1028 Uuid::new_v4(),
1029 "orders",
1030 AssetRelationship::Implements,
1031 );
1032
1033 assert_eq!(link.asset_type, "odcs");
1034 assert_eq!(link.asset_name, "orders");
1035 assert_eq!(link.relationship, Some(AssetRelationship::Implements));
1036 }
1037
1038 #[test]
1039 fn test_timestamp_number_generation() {
1040 use chrono::TimeZone;
1041 let dt = Utc.with_ymd_and_hms(2026, 1, 10, 14, 30, 0).unwrap();
1042 let number = Decision::generate_timestamp_number(&dt);
1043 assert_eq!(number, 2601101430);
1044 }
1045
1046 #[test]
1047 fn test_is_timestamp_number() {
1048 let sequential_decision = Decision::new(1, "Test", "Context", "Decision");
1049 assert!(!sequential_decision.is_timestamp_number());
1050
1051 let timestamp_decision = Decision::new(2601101430, "Test", "Context", "Decision");
1052 assert!(timestamp_decision.is_timestamp_number());
1053 }
1054
1055 #[test]
1056 fn test_timestamp_decision_filename() {
1057 let decision = Decision::new(2601101430, "Test", "Context", "Decision");
1058 assert_eq!(
1059 decision.filename("enterprise"),
1060 "enterprise_adr-2601101430.madr.yaml"
1061 );
1062 }
1063
1064 #[test]
1065 fn test_timestamp_decision_markdown_filename() {
1066 let decision = Decision::new(2601101430, "Test Decision", "Context", "Decision");
1067 let filename = decision.markdown_filename();
1068 assert!(filename.starts_with("ADR-2601101430-"));
1069 assert!(filename.ends_with(".md"));
1070 }
1071
1072 #[test]
1073 fn test_decision_with_consulted_informed() {
1074 let decision = Decision::new(1, "Test", "Context", "Decision")
1075 .add_consulted("security@example.com")
1076 .add_informed("stakeholders@example.com");
1077
1078 assert_eq!(decision.consulted.len(), 1);
1079 assert_eq!(decision.informed.len(), 1);
1080 assert_eq!(decision.consulted[0], "security@example.com");
1081 assert_eq!(decision.informed[0], "stakeholders@example.com");
1082 }
1083
1084 #[test]
1085 fn test_decision_with_authors() {
1086 let decision = Decision::new(1, "Test", "Context", "Decision")
1087 .add_author("author1@example.com")
1088 .add_author("author2@example.com");
1089
1090 assert_eq!(decision.authors.len(), 2);
1091 }
1092
1093 #[test]
1094 fn test_decision_index_with_timestamp_numbering() {
1095 let index = DecisionIndex::new_with_timestamp_numbering();
1096 assert!(index.use_timestamp_numbering);
1097
1098 let next = index.get_next_number();
1100 assert!(next >= 1000000000); }
1102
1103 #[test]
1104 fn test_new_categories() {
1105 assert_eq!(format!("{}", DecisionCategory::Data), "Data");
1106 assert_eq!(format!("{}", DecisionCategory::Integration), "Integration");
1107 }
1108
1109 #[test]
1110 fn test_decision_with_related() {
1111 let related_decision_id = Uuid::new_v4();
1112 let related_knowledge_id = Uuid::new_v4();
1113
1114 let decision = Decision::new(1, "Test", "Context", "Decision")
1115 .add_related_decision(related_decision_id)
1116 .add_related_knowledge(related_knowledge_id);
1117
1118 assert_eq!(decision.related_decisions.len(), 1);
1119 assert_eq!(decision.related_knowledge.len(), 1);
1120 assert_eq!(decision.related_decisions[0], related_decision_id);
1121 assert_eq!(decision.related_knowledge[0], related_knowledge_id);
1122 }
1123
1124 #[test]
1125 fn test_decision_status_draft() {
1126 let decision =
1127 Decision::new(1, "Test", "Context", "Decision").with_status(DecisionStatus::Draft);
1128 assert_eq!(decision.status, DecisionStatus::Draft);
1129 assert_eq!(format!("{}", DecisionStatus::Draft), "Draft");
1130 }
1131}