data_modelling_core/models/
decision.rs

1//! Decision model for MADR-compliant decision records
2//!
3//! Implements the Data Decision Log (DDL) feature for tracking architectural
4//! and data-related decisions using the MADR (Markdown Any Decision Records)
5//! template format.
6//!
7//! ## File Format
8//!
9//! Decision records are stored as `.madr.yaml` files following the naming convention:
10//! `{workspace}_{domain}_adr-{number}.madr.yaml`
11//!
12//! ## Example
13//!
14//! ```yaml
15//! id: 550e8400-e29b-41d4-a716-446655440000
16//! number: 1
17//! title: "Use ODCS v3.1.0 for all data contracts"
18//! status: accepted
19//! category: datadesign
20//! domain: sales
21//! date: 2026-01-07T10:00:00Z
22//! deciders:
23//!   - data-architecture@company.com
24//! context: |
25//!   We need a standard format for defining data contracts.
26//! decision: |
27//!   We will adopt ODCS v3.1.0 as the standard format.
28//! consequences: |
29//!   Positive: Consistent contracts across domains
30//! ```
31
32use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34use uuid::Uuid;
35
36use super::Tag;
37
38/// Decision status in lifecycle
39///
40/// Decisions follow a lifecycle: Draft → Proposed → Accepted → [Deprecated | Superseded | Rejected]
41#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "lowercase")]
43pub enum DecisionStatus {
44    /// Decision is in draft state, not yet proposed
45    Draft,
46    /// Decision has been proposed but not yet accepted
47    #[default]
48    Proposed,
49    /// Decision has been accepted and is in effect
50    Accepted,
51    /// Decision was rejected
52    Rejected,
53    /// Decision has been replaced by another decision
54    Superseded,
55    /// Decision is no longer valid but not replaced
56    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/// Decision category
73///
74/// Categories help organize decisions by their domain of impact.
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "lowercase")]
77pub enum DecisionCategory {
78    /// System architecture decisions
79    #[default]
80    Architecture,
81    /// Technology choices
82    Technology,
83    /// Process-related decisions
84    Process,
85    /// Security-related decisions
86    Security,
87    /// Data-related decisions
88    Data,
89    /// Integration decisions
90    Integration,
91    /// Data design and modeling decisions
92    DataDesign,
93    /// Workflow and process decisions
94    Workflow,
95    /// Data model structure decisions
96    Model,
97    /// Data governance decisions
98    Governance,
99    /// Performance optimization decisions
100    Performance,
101    /// Compliance and regulatory decisions
102    Compliance,
103    /// Infrastructure decisions
104    Infrastructure,
105    /// Tooling choices
106    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/// Priority level for decision drivers
131#[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/// Driver/reason for the decision
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub struct DecisionDriver {
143    /// Description of why this is a driver
144    pub description: String,
145    /// Priority of this driver
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub priority: Option<DriverPriority>,
148}
149
150impl DecisionDriver {
151    /// Create a new decision driver
152    pub fn new(description: impl Into<String>) -> Self {
153        Self {
154            description: description.into(),
155            priority: None,
156        }
157    }
158
159    /// Create a decision driver with priority
160    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/// Option considered during decision making
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct DecisionOption {
171    /// Name of the option
172    pub name: String,
173    /// Description of the option
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub description: Option<String>,
176    /// Advantages of this option
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub pros: Vec<String>,
179    /// Disadvantages of this option
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub cons: Vec<String>,
182    /// Whether this option was selected
183    pub selected: bool,
184}
185
186impl DecisionOption {
187    /// Create a new decision option
188    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    /// Create a decision option with full details
199    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/// Relationship type between a decision and an asset
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(rename_all = "lowercase")]
219pub enum AssetRelationship {
220    /// Decision affects this asset
221    Affects,
222    /// Decision is implemented by this asset
223    Implements,
224    /// Decision deprecates this asset
225    Deprecates,
226}
227
228/// Link to an asset (table, relationship, product, etc.)
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230#[serde(rename_all = "camelCase")]
231pub struct AssetLink {
232    /// Type of asset (odcs, odps, cads, relationship)
233    #[serde(alias = "asset_type")]
234    pub asset_type: String,
235    /// UUID of the linked asset
236    #[serde(alias = "asset_id")]
237    pub asset_id: Uuid,
238    /// Name of the linked asset
239    #[serde(alias = "asset_name")]
240    pub asset_name: String,
241    /// Relationship between decision and asset
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub relationship: Option<AssetRelationship>,
244}
245
246impl AssetLink {
247    /// Create a new asset link
248    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    /// Create an asset link with relationship
262    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/// Compliance assessment for the decision
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
279#[serde(rename_all = "camelCase")]
280pub struct ComplianceAssessment {
281    /// Impact on regulatory requirements
282    #[serde(skip_serializing_if = "Option::is_none", alias = "regulatory_impact")]
283    pub regulatory_impact: Option<String>,
284    /// Privacy impact assessment
285    #[serde(skip_serializing_if = "Option::is_none", alias = "privacy_assessment")]
286    pub privacy_assessment: Option<String>,
287    /// Security impact assessment
288    #[serde(skip_serializing_if = "Option::is_none", alias = "security_assessment")]
289    pub security_assessment: Option<String>,
290    /// Applicable compliance frameworks (GDPR, SOC2, HIPAA, etc.)
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub frameworks: Vec<String>,
293}
294
295impl ComplianceAssessment {
296    /// Check if the assessment is empty
297    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/// Contact details for decision ownership
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
307pub struct DecisionContact {
308    /// Email address
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub email: Option<String>,
311    /// Contact name
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub name: Option<String>,
314    /// Role or team
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub role: Option<String>,
317}
318
319/// RACI matrix for decision responsibility assignment
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
321pub struct RaciMatrix {
322    /// Responsible - Those who do the work to complete the task
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub responsible: Vec<String>,
325    /// Accountable - The one ultimately answerable for the decision
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub accountable: Vec<String>,
328    /// Consulted - Those whose opinions are sought
329    #[serde(default, skip_serializing_if = "Vec::is_empty")]
330    pub consulted: Vec<String>,
331    /// Informed - Those who are kept up-to-date on progress
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub informed: Vec<String>,
334}
335
336impl RaciMatrix {
337    /// Check if the RACI matrix is empty
338    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/// MADR-compliant Decision Record
347///
348/// Represents an architectural or data decision following the MADR template.
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
350#[serde(rename_all = "camelCase")]
351pub struct Decision {
352    /// Unique identifier for the decision
353    pub id: Uuid,
354    /// Decision number - can be sequential (1, 2, 3) or timestamp-based (YYMMDDHHmm format)
355    /// Timestamp format prevents merge conflicts in distributed Git workflows
356    pub number: u64,
357    /// Short title describing the decision
358    pub title: String,
359    /// Current status of the decision
360    pub status: DecisionStatus,
361    /// Category of the decision
362    pub category: DecisionCategory,
363    /// Domain this decision belongs to (optional, string name)
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub domain: Option<String>,
366    /// Domain UUID reference (optional)
367    #[serde(skip_serializing_if = "Option::is_none", alias = "domain_id")]
368    pub domain_id: Option<Uuid>,
369    /// Workspace UUID reference (optional)
370    #[serde(skip_serializing_if = "Option::is_none", alias = "workspace_id")]
371    pub workspace_id: Option<Uuid>,
372
373    // MADR template fields
374    /// Date the decision was made
375    pub date: DateTime<Utc>,
376    /// When the decision was accepted/finalized
377    #[serde(skip_serializing_if = "Option::is_none", alias = "decided_at")]
378    pub decided_at: Option<DateTime<Utc>>,
379    /// Authors of the decision record
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    pub authors: Vec<String>,
382    /// People or teams who made the decision (deciders)
383    #[serde(default, skip_serializing_if = "Vec::is_empty")]
384    pub deciders: Vec<String>,
385    /// People or teams consulted during decision making (RACI - Consulted)
386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
387    pub consulted: Vec<String>,
388    /// People or teams to be informed about the decision (RACI - Informed)
389    #[serde(default, skip_serializing_if = "Vec::is_empty")]
390    pub informed: Vec<String>,
391    /// Problem statement and context for the decision
392    pub context: String,
393    /// Reasons driving this decision
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub drivers: Vec<DecisionDriver>,
396    /// Options that were considered
397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
398    pub options: Vec<DecisionOption>,
399    /// The decision that was made
400    pub decision: String,
401    /// Positive and negative consequences of the decision
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub consequences: Option<String>,
404
405    // Linking
406    /// Assets affected by this decision
407    #[serde(
408        default,
409        skip_serializing_if = "Vec::is_empty",
410        alias = "linked_assets"
411    )]
412    pub linked_assets: Vec<AssetLink>,
413    /// ID of the decision this supersedes
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub supersedes: Option<Uuid>,
416    /// ID of the decision that superseded this
417    #[serde(skip_serializing_if = "Option::is_none", alias = "superseded_by")]
418    pub superseded_by: Option<Uuid>,
419    /// IDs of related decisions
420    #[serde(
421        default,
422        skip_serializing_if = "Vec::is_empty",
423        alias = "related_decisions"
424    )]
425    pub related_decisions: Vec<Uuid>,
426    /// IDs of related knowledge articles
427    #[serde(
428        default,
429        skip_serializing_if = "Vec::is_empty",
430        alias = "related_knowledge"
431    )]
432    pub related_knowledge: Vec<Uuid>,
433
434    // Compliance (from feature request)
435    /// Compliance assessment
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub compliance: Option<ComplianceAssessment>,
438
439    // Confirmation tracking (from feature request)
440    /// Date the decision was confirmed/reviewed
441    #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_date")]
442    pub confirmation_date: Option<DateTime<Utc>>,
443    /// Notes from confirmation review
444    #[serde(skip_serializing_if = "Option::is_none", alias = "confirmation_notes")]
445    pub confirmation_notes: Option<String>,
446
447    // Standard metadata
448    /// Tags for categorization
449    #[serde(default, skip_serializing_if = "Vec::is_empty")]
450    pub tags: Vec<Tag>,
451    /// Additional notes
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub notes: Option<String>,
454
455    /// Creation timestamp
456    #[serde(alias = "created_at")]
457    pub created_at: DateTime<Utc>,
458    /// Last modification timestamp
459    #[serde(alias = "updated_at")]
460    pub updated_at: DateTime<Utc>,
461}
462
463impl Decision {
464    /// Create a new decision with required fields
465    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    /// Create a new decision with a timestamp-based number (YYMMDDHHmm format)
508    /// This format prevents merge conflicts in distributed Git workflows
509    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    /// Generate a timestamp-based decision number in YYMMDDHHmm format
520    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    /// Generate a deterministic UUID for a decision based on its number
526    pub fn generate_id(number: u64) -> Uuid {
527        // Use UUID v5 with a namespace for decisions
528        let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(); // URL namespace
529        let name = format!("decision:{}", number);
530        Uuid::new_v5(&namespace, name.as_bytes())
531    }
532
533    /// Add an author
534    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    /// Set consulted parties (RACI - Consulted)
541    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    /// Set informed parties (RACI - Informed)
548    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    /// Add a related decision
555    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    /// Add a related knowledge article
562    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    /// Set decided_at timestamp
569    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    /// Set the domain ID
576    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    /// Set the workspace ID
583    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    /// Set the decision status
590    pub fn with_status(mut self, status: DecisionStatus) -> Self {
591        self.status = status;
592        self.updated_at = Utc::now();
593        self
594    }
595
596    /// Set the decision category
597    pub fn with_category(mut self, category: DecisionCategory) -> Self {
598        self.category = category;
599        self.updated_at = Utc::now();
600        self
601    }
602
603    /// Set the domain
604    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    /// Add a decider
611    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    /// Add a driver
618    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    /// Add an option
625    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    /// Set consequences
632    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    /// Add an asset link
639    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    /// Set compliance assessment
646    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    /// Mark this decision as superseding another
653    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    /// Mark this decision as superseded by another
660    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    /// Add a tag
667    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    /// Check if the decision number is timestamp-based (YYMMDDHHmm format - 10 digits)
674    pub fn is_timestamp_number(&self) -> bool {
675        self.number >= 1000000000 && self.number <= 9999999999
676    }
677
678    /// Format the decision number for display
679    /// Returns "ADR-0001" for sequential or "ADR-2601101234" for timestamp-based
680    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    /// Generate the YAML filename for this decision
689    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    /// Generate the Markdown filename for this decision
712    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    /// Import from YAML
722    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
723        serde_yaml::from_str(yaml_content)
724    }
725
726    /// Export to YAML
727    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
728        serde_yaml::to_string(self)
729    }
730
731    /// Export to pretty YAML
732    pub fn to_yaml_pretty(&self) -> Result<String, serde_yaml::Error> {
733        // serde_yaml already produces pretty output
734        serde_yaml::to_string(self)
735    }
736}
737
738/// Decision index entry for the decisions.yaml file
739#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
740pub struct DecisionIndexEntry {
741    /// Decision number (can be sequential or timestamp-based)
742    pub number: u64,
743    /// Decision UUID
744    pub id: Uuid,
745    /// Decision title
746    pub title: String,
747    /// Decision status
748    pub status: DecisionStatus,
749    /// Decision category
750    pub category: DecisionCategory,
751    /// Domain (if applicable)
752    #[serde(skip_serializing_if = "Option::is_none")]
753    pub domain: Option<String>,
754    /// Filename of the decision YAML file
755    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(), // Set by caller
768        }
769    }
770}
771
772/// Decision log index (decisions.yaml)
773#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
774pub struct DecisionIndex {
775    /// Schema version
776    #[serde(alias = "schema_version")]
777    pub schema_version: String,
778    /// Last update timestamp
779    #[serde(skip_serializing_if = "Option::is_none", alias = "last_updated")]
780    pub last_updated: Option<DateTime<Utc>>,
781    /// List of decisions
782    #[serde(default)]
783    pub decisions: Vec<DecisionIndexEntry>,
784    /// Next available decision number (for sequential numbering)
785    #[serde(alias = "next_number")]
786    pub next_number: u64,
787    /// Whether to use timestamp-based numbering (YYMMDDHHmm format)
788    #[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    /// Create a new empty decision index
800    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    /// Create a new decision index with timestamp-based numbering
811    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    /// Add a decision to the index
822    pub fn add_decision(&mut self, decision: &Decision, filename: String) {
823        let mut entry = DecisionIndexEntry::from(decision);
824        entry.file = filename;
825
826        // Remove existing entry with same number if present
827        self.decisions.retain(|d| d.number != decision.number);
828        self.decisions.push(entry);
829
830        // Sort by number
831        self.decisions.sort_by_key(|d| d.number);
832
833        // Update next number only for sequential numbering
834        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    /// Get the next available decision number
842    /// For timestamp-based numbering, generates a new timestamp
843    /// For sequential numbering, returns the next sequential number
844    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    /// Find a decision by number
853    pub fn find_by_number(&self, number: u64) -> Option<&DecisionIndexEntry> {
854        self.decisions.iter().find(|d| d.number == number)
855    }
856
857    /// Import from YAML
858    pub fn from_yaml(yaml_content: &str) -> Result<Self, serde_yaml::Error> {
859        serde_yaml::from_str(yaml_content)
860    }
861
862    /// Export to YAML
863    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
864        serde_yaml::to_string(self)
865    }
866}
867
868/// Sanitize a name for use in filenames
869fn 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
879/// Create a URL-friendly slug from a title
880fn 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) // Limit slug length
892        .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        // Same number should generate same ID
942        assert_eq!(id1, id2);
943        // Different numbers should generate different IDs
944        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        // The next number should be a timestamp
1099        let next = index.get_next_number();
1100        assert!(next >= 1000000000); // Timestamp format check
1101    }
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}