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 #[default]
46 Proposed,
47 Accepted,
49 Deprecated,
51 Superseded,
53}
54
55impl std::fmt::Display for DecisionStatus {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 DecisionStatus::Proposed => write!(f, "Proposed"),
59 DecisionStatus::Accepted => write!(f, "Accepted"),
60 DecisionStatus::Deprecated => write!(f, "Deprecated"),
61 DecisionStatus::Superseded => write!(f, "Superseded"),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "lowercase")]
71pub enum DecisionCategory {
72 #[default]
74 Architecture,
75 DataDesign,
77 Workflow,
79 Model,
81 Governance,
83 Security,
85 Performance,
87 Compliance,
89 Infrastructure,
91 Tooling,
93}
94
95impl std::fmt::Display for DecisionCategory {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 DecisionCategory::Architecture => write!(f, "Architecture"),
99 DecisionCategory::DataDesign => write!(f, "Data Design"),
100 DecisionCategory::Workflow => write!(f, "Workflow"),
101 DecisionCategory::Model => write!(f, "Model"),
102 DecisionCategory::Governance => write!(f, "Governance"),
103 DecisionCategory::Security => write!(f, "Security"),
104 DecisionCategory::Performance => write!(f, "Performance"),
105 DecisionCategory::Compliance => write!(f, "Compliance"),
106 DecisionCategory::Infrastructure => write!(f, "Infrastructure"),
107 DecisionCategory::Tooling => write!(f, "Tooling"),
108 }
109 }
110}
111
112#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(rename_all = "lowercase")]
115pub enum DriverPriority {
116 High,
117 #[default]
118 Medium,
119 Low,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct DecisionDriver {
125 pub description: String,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub priority: Option<DriverPriority>,
130}
131
132impl DecisionDriver {
133 pub fn new(description: impl Into<String>) -> Self {
135 Self {
136 description: description.into(),
137 priority: None,
138 }
139 }
140
141 pub fn with_priority(description: impl Into<String>, priority: DriverPriority) -> Self {
143 Self {
144 description: description.into(),
145 priority: Some(priority),
146 }
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct DecisionOption {
153 pub name: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub description: Option<String>,
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub pros: Vec<String>,
161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub cons: Vec<String>,
164 pub selected: bool,
166}
167
168impl DecisionOption {
169 pub fn new(name: impl Into<String>, selected: bool) -> Self {
171 Self {
172 name: name.into(),
173 description: None,
174 pros: Vec::new(),
175 cons: Vec::new(),
176 selected,
177 }
178 }
179
180 pub fn with_details(
182 name: impl Into<String>,
183 description: impl Into<String>,
184 pros: Vec<String>,
185 cons: Vec<String>,
186 selected: bool,
187 ) -> Self {
188 Self {
189 name: name.into(),
190 description: Some(description.into()),
191 pros,
192 cons,
193 selected,
194 }
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200#[serde(rename_all = "lowercase")]
201pub enum AssetRelationship {
202 Affects,
204 Implements,
206 Deprecates,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212pub struct AssetLink {
213 pub asset_type: String,
215 pub asset_id: Uuid,
217 pub asset_name: String,
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub relationship: Option<AssetRelationship>,
222}
223
224impl AssetLink {
225 pub fn new(
227 asset_type: impl Into<String>,
228 asset_id: Uuid,
229 asset_name: impl Into<String>,
230 ) -> Self {
231 Self {
232 asset_type: asset_type.into(),
233 asset_id,
234 asset_name: asset_name.into(),
235 relationship: None,
236 }
237 }
238
239 pub fn with_relationship(
241 asset_type: impl Into<String>,
242 asset_id: Uuid,
243 asset_name: impl Into<String>,
244 relationship: AssetRelationship,
245 ) -> Self {
246 Self {
247 asset_type: asset_type.into(),
248 asset_id,
249 asset_name: asset_name.into(),
250 relationship: Some(relationship),
251 }
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
257pub struct ComplianceAssessment {
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub regulatory_impact: Option<String>,
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub privacy_assessment: Option<String>,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub security_assessment: Option<String>,
267 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub frameworks: Vec<String>,
270}
271
272impl ComplianceAssessment {
273 pub fn is_empty(&self) -> bool {
275 self.regulatory_impact.is_none()
276 && self.privacy_assessment.is_none()
277 && self.security_assessment.is_none()
278 && self.frameworks.is_empty()
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
284pub struct DecisionContact {
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub email: Option<String>,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub name: Option<String>,
291 #[serde(skip_serializing_if = "Option::is_none")]
293 pub role: Option<String>,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
300pub struct Decision {
301 pub id: Uuid,
303 pub number: u32,
305 pub title: String,
307 pub status: DecisionStatus,
309 pub category: DecisionCategory,
311 #[serde(skip_serializing_if = "Option::is_none")]
313 pub domain: Option<String>,
314
315 pub date: DateTime<Utc>,
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
320 pub deciders: Vec<String>,
321 pub context: String,
323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
325 pub drivers: Vec<DecisionDriver>,
326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub options: Vec<DecisionOption>,
329 pub decision: String,
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub consequences: Option<String>,
334
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
338 pub linked_assets: Vec<AssetLink>,
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub supersedes: Option<Uuid>,
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub superseded_by: Option<Uuid>,
345
346 #[serde(skip_serializing_if = "Option::is_none")]
349 pub compliance: Option<ComplianceAssessment>,
350
351 #[serde(skip_serializing_if = "Option::is_none")]
354 pub confirmation_date: Option<DateTime<Utc>>,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub confirmation_notes: Option<String>,
358
359 #[serde(default, skip_serializing_if = "Vec::is_empty")]
362 pub tags: Vec<Tag>,
363 #[serde(skip_serializing_if = "Option::is_none")]
365 pub notes: Option<String>,
366
367 pub created_at: DateTime<Utc>,
369 pub updated_at: DateTime<Utc>,
371}
372
373impl Decision {
374 pub fn new(
376 number: u32,
377 title: impl Into<String>,
378 context: impl Into<String>,
379 decision: impl Into<String>,
380 ) -> Self {
381 let now = Utc::now();
382 Self {
383 id: Self::generate_id(number),
384 number,
385 title: title.into(),
386 status: DecisionStatus::Proposed,
387 category: DecisionCategory::Architecture,
388 domain: None,
389 date: now,
390 deciders: Vec::new(),
391 context: context.into(),
392 drivers: Vec::new(),
393 options: Vec::new(),
394 decision: decision.into(),
395 consequences: None,
396 linked_assets: Vec::new(),
397 supersedes: None,
398 superseded_by: None,
399 compliance: None,
400 confirmation_date: None,
401 confirmation_notes: None,
402 tags: Vec::new(),
403 notes: None,
404 created_at: now,
405 updated_at: now,
406 }
407 }
408
409 pub fn generate_id(number: u32) -> Uuid {
411 let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); let name = format!("decision:{}", number);
414 Uuid::new_v5(&namespace, name.as_bytes())
415 }
416
417 pub fn with_status(mut self, status: DecisionStatus) -> Self {
419 self.status = status;
420 self.updated_at = Utc::now();
421 self
422 }
423
424 pub fn with_category(mut self, category: DecisionCategory) -> Self {
426 self.category = category;
427 self.updated_at = Utc::now();
428 self
429 }
430
431 pub fn with_domain(mut self, domain: impl Into<String>) -> Self {
433 self.domain = Some(domain.into());
434 self.updated_at = Utc::now();
435 self
436 }
437
438 pub fn add_decider(mut self, decider: impl Into<String>) -> Self {
440 self.deciders.push(decider.into());
441 self.updated_at = Utc::now();
442 self
443 }
444
445 pub fn add_driver(mut self, driver: DecisionDriver) -> Self {
447 self.drivers.push(driver);
448 self.updated_at = Utc::now();
449 self
450 }
451
452 pub fn add_option(mut self, option: DecisionOption) -> Self {
454 self.options.push(option);
455 self.updated_at = Utc::now();
456 self
457 }
458
459 pub fn with_consequences(mut self, consequences: impl Into<String>) -> Self {
461 self.consequences = Some(consequences.into());
462 self.updated_at = Utc::now();
463 self
464 }
465
466 pub fn add_asset_link(mut self, link: AssetLink) -> Self {
468 self.linked_assets.push(link);
469 self.updated_at = Utc::now();
470 self
471 }
472
473 pub fn with_compliance(mut self, compliance: ComplianceAssessment) -> Self {
475 self.compliance = Some(compliance);
476 self.updated_at = Utc::now();
477 self
478 }
479
480 pub fn supersedes_decision(mut self, other_id: Uuid) -> Self {
482 self.supersedes = Some(other_id);
483 self.updated_at = Utc::now();
484 self
485 }
486
487 pub fn superseded_by_decision(&mut self, other_id: Uuid) {
489 self.superseded_by = Some(other_id);
490 self.status = DecisionStatus::Superseded;
491 self.updated_at = Utc::now();
492 }
493
494 pub fn add_tag(mut self, tag: Tag) -> Self {
496 self.tags.push(tag);
497 self.updated_at = Utc::now();
498 self
499 }
500
501 pub fn filename(&self, workspace_name: &str) -> String {
503 match &self.domain {
504 Some(domain) => format!(
505 "{}_{}_adr-{:04}.madr.yaml",
506 sanitize_name(workspace_name),
507 sanitize_name(domain),
508 self.number
509 ),
510 None => format!(
511 "{}_adr-{:04}.madr.yaml",
512 sanitize_name(workspace_name),
513 self.number
514 ),
515 }
516 }
517
518 pub fn markdown_filename(&self) -> String {
520 let slug = slugify(&self.title);
521 format!("ADR-{:04}-{}.md", self.number, slug)
522 }
523
524 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
526 serde_yaml::from_str(yaml_content)
527 }
528
529 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
531 serde_yaml::to_string(self)
532 }
533
534 pub fn to_yaml_pretty(&self) -> Result<String, serde_yaml::Error> {
536 serde_yaml::to_string(self)
538 }
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
543pub struct DecisionIndexEntry {
544 pub number: u32,
546 pub id: Uuid,
548 pub title: String,
550 pub status: DecisionStatus,
552 pub category: DecisionCategory,
554 #[serde(skip_serializing_if = "Option::is_none")]
556 pub domain: Option<String>,
557 pub file: String,
559}
560
561impl From<&Decision> for DecisionIndexEntry {
562 fn from(decision: &Decision) -> Self {
563 Self {
564 number: decision.number,
565 id: decision.id,
566 title: decision.title.clone(),
567 status: decision.status.clone(),
568 category: decision.category.clone(),
569 domain: decision.domain.clone(),
570 file: String::new(), }
572 }
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
577pub struct DecisionIndex {
578 pub schema_version: String,
580 #[serde(skip_serializing_if = "Option::is_none")]
582 pub last_updated: Option<DateTime<Utc>>,
583 #[serde(default)]
585 pub decisions: Vec<DecisionIndexEntry>,
586 pub next_number: u32,
588}
589
590impl Default for DecisionIndex {
591 fn default() -> Self {
592 Self::new()
593 }
594}
595
596impl DecisionIndex {
597 pub fn new() -> Self {
599 Self {
600 schema_version: "1.0".to_string(),
601 last_updated: Some(Utc::now()),
602 decisions: Vec::new(),
603 next_number: 1,
604 }
605 }
606
607 pub fn add_decision(&mut self, decision: &Decision, filename: String) {
609 let mut entry = DecisionIndexEntry::from(decision);
610 entry.file = filename;
611
612 self.decisions.retain(|d| d.number != decision.number);
614 self.decisions.push(entry);
615
616 self.decisions.sort_by_key(|d| d.number);
618
619 if decision.number >= self.next_number {
621 self.next_number = decision.number + 1;
622 }
623
624 self.last_updated = Some(Utc::now());
625 }
626
627 pub fn get_next_number(&self) -> u32 {
629 self.next_number
630 }
631
632 pub fn find_by_number(&self, number: u32) -> Option<&DecisionIndexEntry> {
634 self.decisions.iter().find(|d| d.number == number)
635 }
636
637 pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
639 serde_yaml::from_str(yaml_content)
640 }
641
642 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
644 serde_yaml::to_string(self)
645 }
646}
647
648fn sanitize_name(name: &str) -> String {
650 name.chars()
651 .map(|c| match c {
652 ' ' | '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
653 _ => c,
654 })
655 .collect::<String>()
656 .to_lowercase()
657}
658
659fn slugify(title: &str) -> String {
661 title
662 .to_lowercase()
663 .chars()
664 .map(|c| if c.is_alphanumeric() { c } else { '-' })
665 .collect::<String>()
666 .split('-')
667 .filter(|s| !s.is_empty())
668 .collect::<Vec<_>>()
669 .join("-")
670 .chars()
671 .take(50) .collect()
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn test_decision_new() {
681 let decision = Decision::new(
682 1,
683 "Use ODCS v3.1.0",
684 "We need a standard format",
685 "We will use ODCS v3.1.0",
686 );
687
688 assert_eq!(decision.number, 1);
689 assert_eq!(decision.title, "Use ODCS v3.1.0");
690 assert_eq!(decision.status, DecisionStatus::Proposed);
691 assert_eq!(decision.category, DecisionCategory::Architecture);
692 }
693
694 #[test]
695 fn test_decision_builder_pattern() {
696 let decision = Decision::new(1, "Test", "Context", "Decision")
697 .with_status(DecisionStatus::Accepted)
698 .with_category(DecisionCategory::DataDesign)
699 .with_domain("sales")
700 .add_decider("team@example.com")
701 .add_driver(DecisionDriver::with_priority(
702 "Need consistency",
703 DriverPriority::High,
704 ))
705 .with_consequences("Better consistency");
706
707 assert_eq!(decision.status, DecisionStatus::Accepted);
708 assert_eq!(decision.category, DecisionCategory::DataDesign);
709 assert_eq!(decision.domain, Some("sales".to_string()));
710 assert_eq!(decision.deciders.len(), 1);
711 assert_eq!(decision.drivers.len(), 1);
712 assert!(decision.consequences.is_some());
713 }
714
715 #[test]
716 fn test_decision_id_generation() {
717 let id1 = Decision::generate_id(1);
718 let id2 = Decision::generate_id(1);
719 let id3 = Decision::generate_id(2);
720
721 assert_eq!(id1, id2);
723 assert_ne!(id1, id3);
725 }
726
727 #[test]
728 fn test_decision_filename() {
729 let decision = Decision::new(1, "Test", "Context", "Decision");
730 assert_eq!(
731 decision.filename("enterprise"),
732 "enterprise_adr-0001.madr.yaml"
733 );
734
735 let decision_with_domain = decision.with_domain("sales");
736 assert_eq!(
737 decision_with_domain.filename("enterprise"),
738 "enterprise_sales_adr-0001.madr.yaml"
739 );
740 }
741
742 #[test]
743 fn test_decision_markdown_filename() {
744 let decision = Decision::new(
745 1,
746 "Use ODCS v3.1.0 for all data contracts",
747 "Context",
748 "Decision",
749 );
750 let filename = decision.markdown_filename();
751 assert!(filename.starts_with("ADR-0001-"));
752 assert!(filename.ends_with(".md"));
753 }
754
755 #[test]
756 fn test_decision_yaml_roundtrip() {
757 let decision = Decision::new(1, "Test Decision", "Some context", "The decision")
758 .with_status(DecisionStatus::Accepted)
759 .with_domain("test");
760
761 let yaml = decision.to_yaml().unwrap();
762 let parsed = Decision::from_yaml(&yaml).unwrap();
763
764 assert_eq!(decision.id, parsed.id);
765 assert_eq!(decision.title, parsed.title);
766 assert_eq!(decision.status, parsed.status);
767 assert_eq!(decision.domain, parsed.domain);
768 }
769
770 #[test]
771 fn test_decision_index() {
772 let mut index = DecisionIndex::new();
773 assert_eq!(index.get_next_number(), 1);
774
775 let decision1 = Decision::new(1, "First", "Context", "Decision");
776 index.add_decision(&decision1, "test_adr-0001.madr.yaml".to_string());
777
778 assert_eq!(index.decisions.len(), 1);
779 assert_eq!(index.get_next_number(), 2);
780
781 let decision2 = Decision::new(2, "Second", "Context", "Decision");
782 index.add_decision(&decision2, "test_adr-0002.madr.yaml".to_string());
783
784 assert_eq!(index.decisions.len(), 2);
785 assert_eq!(index.get_next_number(), 3);
786 }
787
788 #[test]
789 fn test_slugify() {
790 assert_eq!(slugify("Use ODCS v3.1.0"), "use-odcs-v3-1-0");
791 assert_eq!(slugify("Hello World"), "hello-world");
792 assert_eq!(slugify("test--double"), "test-double");
793 }
794
795 #[test]
796 fn test_decision_status_display() {
797 assert_eq!(format!("{}", DecisionStatus::Proposed), "Proposed");
798 assert_eq!(format!("{}", DecisionStatus::Accepted), "Accepted");
799 assert_eq!(format!("{}", DecisionStatus::Deprecated), "Deprecated");
800 assert_eq!(format!("{}", DecisionStatus::Superseded), "Superseded");
801 }
802
803 #[test]
804 fn test_asset_link() {
805 let link = AssetLink::with_relationship(
806 "odcs",
807 Uuid::new_v4(),
808 "orders",
809 AssetRelationship::Implements,
810 );
811
812 assert_eq!(link.asset_type, "odcs");
813 assert_eq!(link.asset_name, "orders");
814 assert_eq!(link.relationship, Some(AssetRelationship::Implements));
815 }
816}